From 8882769bd37a0ef8daf737c53bf33d6271b45f76 Mon Sep 17 00:00:00 2001 From: Deborah Bello Date: Sun, 30 Aug 2026 11:03:42 +0100 Subject: [PATCH 1/2] feat: auth login/refresh/email-verify + waitlist referral system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #589 — POST /api/v1/auth/login (bcrypt + JWT) Closes #590 — POST /api/v1/auth/refresh (token rotation, hash in DB) Closes #592 — GET /api/v1/auth/verify-email + resend (24h expiry, emailVerified flag) Closes #690 — POST /api/v1/waitlist/join with referral code (unique code per member, +5 position, no self-referral) --- backend/package.json | 28 ++++++ backend/src/app.module.ts | 21 +++++ backend/src/auth/auth.controller.ts | 31 +++++++ backend/src/auth/auth.module.ts | 18 ++++ backend/src/auth/auth.service.ts | 98 +++++++++++++++++++++ backend/src/auth/refresh-token.entity.ts | 20 +++++ backend/src/merchant/merchant.entity.ts | 28 ++++++ backend/src/waitlist/waitlist.controller.ts | 22 +++++ backend/src/waitlist/waitlist.entity.ts | 34 +++++++ backend/src/waitlist/waitlist.module.ts | 12 +++ backend/src/waitlist/waitlist.service.ts | 67 ++++++++++++++ backend/tsconfig.json | 17 ++++ 12 files changed, 396 insertions(+) create mode 100644 backend/package.json create mode 100644 backend/src/app.module.ts create mode 100644 backend/src/auth/auth.controller.ts create mode 100644 backend/src/auth/auth.module.ts create mode 100644 backend/src/auth/auth.service.ts create mode 100644 backend/src/auth/refresh-token.entity.ts create mode 100644 backend/src/merchant/merchant.entity.ts create mode 100644 backend/src/waitlist/waitlist.controller.ts create mode 100644 backend/src/waitlist/waitlist.entity.ts create mode 100644 backend/src/waitlist/waitlist.module.ts create mode 100644 backend/src/waitlist/waitlist.service.ts create mode 100644 backend/tsconfig.json diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 00000000..f50c781a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,28 @@ +{ + "name": "dabdub-backend", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "nest build", + "start": "node dist/main", + "start:dev": "nest start --watch" + }, + "dependencies": { + "@nestjs/common": "10.4.15", + "@nestjs/core": "10.4.15", + "@nestjs/jwt": "10.2.0", + "@nestjs/platform-express": "10.4.15", + "@nestjs/typeorm": "10.0.2", + "bcrypt": "5.1.1", + "pg": "8.13.1", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.1", + "typeorm": "0.3.20" + }, + "devDependencies": { + "@nestjs/cli": "10.4.9", + "@types/bcrypt": "5.0.2", + "@types/node": "20.17.9", + "typescript": "5.7.3" + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 00000000..68739e99 --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from './auth/auth.module'; +import { Merchant } from './merchant/merchant.entity'; +import { RefreshToken } from './auth/refresh-token.entity'; +import { WaitlistModule } from './waitlist/waitlist.module'; +import { WaitlistEntry } from './waitlist/waitlist.entity'; + +@Module({ + imports: [ + TypeOrmModule.forRoot({ + type: 'postgres', + url: process.env.DATABASE_URL, + entities: [Merchant, RefreshToken, WaitlistEntry], + synchronize: process.env.NODE_ENV !== 'production', + }), + AuthModule, + WaitlistModule, + ], +}) +export class AppModule {} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 00000000..773bfc2c --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { AuthService } from './auth.service'; + +@Controller('api/v1/auth') +export class AuthController { + constructor(private readonly auth: AuthService) {} + + /** Issue #589 — POST /api/v1/auth/login */ + @Post('login') + login(@Body() body: { email: string; password: string }) { + return this.auth.login(body.email, body.password); + } + + /** Issue #590 — POST /api/v1/auth/refresh */ + @Post('refresh') + refresh(@Body() body: { refreshToken: string }) { + return this.auth.refresh(body.refreshToken); + } + + /** Issue #592 — GET /api/v1/auth/verify-email?token=xxx */ + @Get('verify-email') + verifyEmail(@Query('token') token: string) { + return this.auth.verifyEmail(token); + } + + /** Issue #592 — POST /api/v1/auth/resend-verification */ + @Post('resend-verification') + resendVerification(@Body() body: { email: string }) { + return this.auth.resendVerification(body.email); + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 00000000..f5dc235a --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Merchant } from '../merchant/merchant.entity'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { RefreshToken } from './refresh-token.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Merchant, RefreshToken]), + JwtModule.register({}), + ], + controllers: [AuthController], + providers: [AuthService], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 00000000..7b12cfdf --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,98 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import * as crypto from 'crypto'; +import { Merchant } from '../merchant/merchant.entity'; +import { RefreshToken } from './refresh-token.entity'; + +@Injectable() +export class AuthService { + constructor( + @InjectRepository(Merchant) + private readonly merchants: Repository, + @InjectRepository(RefreshToken) + private readonly tokens: Repository, + private readonly jwt: JwtService, + ) {} + + // Issue #589 — JWT login + async login(email: string, password: string) { + const merchant = await this.merchants.findOne({ where: { email } }); + if (!merchant || !(await bcrypt.compare(password, merchant.passwordHash))) { + throw new UnauthorizedException('Invalid credentials'); + } + const accessToken = this.signAccess(merchant); + const refreshToken = await this.issueRefresh(merchant.id); + return { accessToken, refreshToken, merchant: this.sanitize(merchant) }; + } + + // Issue #590 — refresh token rotation + async refresh(rawToken: string) { + const hash = this.hashToken(rawToken); + const record = await this.tokens.findOne({ where: { tokenHash: hash }, relations: ['merchant'] }); + if (!record || record.expiresAt < new Date()) { + throw new UnauthorizedException('Invalid or expired refresh token'); + } + await this.tokens.remove(record); // rotation: invalidate old token + const accessToken = this.signAccess(record.merchant); + const refreshToken = await this.issueRefresh(record.merchant.id); + return { accessToken, refreshToken }; + } + + // Issue #592 — verify email + async verifyEmail(token: string) { + const merchant = await this.merchants.findOne({ where: { emailVerifyToken: token } }); + if (!merchant || !merchant.emailVerifyExpiry || merchant.emailVerifyExpiry < new Date()) { + throw new UnauthorizedException('Verification link is invalid or expired'); + } + merchant.emailVerified = true; + merchant.emailVerifyToken = null; + merchant.emailVerifyExpiry = null; + await this.merchants.save(merchant); + return { message: 'Email verified successfully' }; + } + + async resendVerification(email: string) { + const merchant = await this.merchants.findOne({ where: { email } }); + if (!merchant || merchant.emailVerified) return; // silent no-op for unverified vs already verified + await this.setVerifyToken(merchant); + // caller (controller/mailer) sends the email + return merchant; + } + + // helpers + async setVerifyToken(merchant: Merchant) { + merchant.emailVerifyToken = crypto.randomBytes(32).toString('hex'); + merchant.emailVerifyExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); + return this.merchants.save(merchant); + } + + private signAccess(merchant: Merchant) { + return this.jwt.sign( + { sub: merchant.id, email: merchant.email }, + { secret: process.env.JWT_SECRET, expiresIn: process.env.JWT_EXPIRY ?? '15m' }, + ); + } + + private async issueRefresh(merchantId: string) { + const raw = crypto.randomBytes(40).toString('hex'); + const record = this.tokens.create({ + tokenHash: this.hashToken(raw), + merchantId, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }); + await this.tokens.save(record); + return raw; + } + + private hashToken(raw: string) { + return crypto.createHash('sha256').update(raw).digest('hex'); + } + + private sanitize(m: Merchant) { + const { passwordHash, emailVerifyToken, ...safe } = m; + return safe; + } +} diff --git a/backend/src/auth/refresh-token.entity.ts b/backend/src/auth/refresh-token.entity.ts new file mode 100644 index 00000000..206e67b2 --- /dev/null +++ b/backend/src/auth/refresh-token.entity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Merchant } from '../merchant/merchant.entity'; + +@Entity('refresh_tokens') +export class RefreshToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + tokenHash: string; + + @Column() + merchantId: string; + + @ManyToOne(() => Merchant, { onDelete: 'CASCADE' }) + merchant: Merchant; + + @Column() + expiresAt: Date; +} diff --git a/backend/src/merchant/merchant.entity.ts b/backend/src/merchant/merchant.entity.ts new file mode 100644 index 00000000..ff922689 --- /dev/null +++ b/backend/src/merchant/merchant.entity.ts @@ -0,0 +1,28 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('merchants') +export class Merchant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + email: string; + + @Column() + passwordHash: string; + + @Column({ nullable: true }) + businessName: string; + + @Column({ default: false }) + emailVerified: boolean; + + @Column({ nullable: true, type: 'varchar' }) + emailVerifyToken: string | null; + + @Column({ nullable: true, type: 'timestamptz' }) + emailVerifyExpiry: Date | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/waitlist/waitlist.controller.ts b/backend/src/waitlist/waitlist.controller.ts new file mode 100644 index 00000000..7f678972 --- /dev/null +++ b/backend/src/waitlist/waitlist.controller.ts @@ -0,0 +1,22 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { WaitlistService } from './waitlist.service'; + +@Controller('api/v1/waitlist') +export class WaitlistController { + constructor(private readonly waitlist: WaitlistService) {} + + /** Issue #690 — POST /api/v1/waitlist/join */ + @Post('join') + join( + @Body() + body: { + email: string; + username?: string; + businessName?: string; + country?: string; + referralCode?: string; + }, + ) { + return this.waitlist.join(body); + } +} diff --git a/backend/src/waitlist/waitlist.entity.ts b/backend/src/waitlist/waitlist.entity.ts new file mode 100644 index 00000000..2b41ecfc --- /dev/null +++ b/backend/src/waitlist/waitlist.entity.ts @@ -0,0 +1,34 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('waitlist') +export class WaitlistEntry { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + email: string; + + @Column({ nullable: true }) + username: string; + + @Column({ nullable: true }) + businessName: string; + + @Column({ nullable: true }) + country: string; + + /** Unique referral code this member can share */ + @Column({ unique: true }) + referralCode: string; + + /** Number of successful referrals made by this member */ + @Column({ default: 0 }) + referralCount: number; + + /** Queue position — lower = earlier */ + @Column() + position: number; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/waitlist/waitlist.module.ts b/backend/src/waitlist/waitlist.module.ts new file mode 100644 index 00000000..50200824 --- /dev/null +++ b/backend/src/waitlist/waitlist.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { WaitlistController } from './waitlist.controller'; +import { WaitlistEntry } from './waitlist.entity'; +import { WaitlistService } from './waitlist.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([WaitlistEntry])], + controllers: [WaitlistController], + providers: [WaitlistService], +}) +export class WaitlistModule {} diff --git a/backend/src/waitlist/waitlist.service.ts b/backend/src/waitlist/waitlist.service.ts new file mode 100644 index 00000000..58b15841 --- /dev/null +++ b/backend/src/waitlist/waitlist.service.ts @@ -0,0 +1,67 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { randomBytes } from 'crypto'; +import { WaitlistEntry } from './waitlist.entity'; + +@Injectable() +export class WaitlistService { + constructor( + @InjectRepository(WaitlistEntry) + private readonly entries: Repository, + private readonly ds: DataSource, + ) {} + + /** Issue #690 — join with optional referral code */ + async join(dto: { email: string; username?: string; businessName?: string; country?: string; referralCode?: string }) { + const exists = await this.entries.findOne({ where: { email: dto.email } }); + if (exists) throw new ConflictException('Email already on waitlist'); + + const maxPos = await this.entries.maximum('position') ?? 0; + + const entry = this.entries.create({ + ...dto, + position: maxPos + 1, + referralCode: randomBytes(6).toString('hex'), // unique 12-char code + }); + + await this.entries.save(entry); + + // Process referral after saving (Issue #690) + if (dto.referralCode) { + await this.applyReferral(entry, dto.referralCode); + } + + return entry; + } + + /** Issue #690 — apply referral: move referrer up 5 positions, prevent self-referral */ + private async applyReferral(newEntry: WaitlistEntry, code: string) { + const referrer = await this.entries.findOne({ where: { referralCode: code } }); + if (!referrer) return; // invalid code — silently ignore + + if (referrer.id === newEntry.id) { + throw new BadRequestException('Self-referral is not allowed'); + } + + // Move referrer up 5 positions (lower position = earlier in queue) + await this.ds.transaction(async (em) => { + const newPosition = Math.max(1, referrer.position - 5); + // Shift everyone between newPosition and referrer.position - 1 down by 1 + await em + .createQueryBuilder() + .update(WaitlistEntry) + .set({ position: () => 'position + 1' }) + .where('position >= :start AND position < :end AND id != :id', { + start: newPosition, + end: referrer.position, + id: referrer.id, + }) + .execute(); + + referrer.position = newPosition; + referrer.referralCount += 1; + await em.save(referrer); + }); + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 00000000..2ee18fcb --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "strict": true, + "skipLibCheck": true, + "strictNullChecks": true + } +} From 81b4623eb37ed40028bb80febb0aae105e8c4a8e Mon Sep 17 00:00:00 2001 From: Deborah Bello Date: Sun, 30 Aug 2026 11:06:54 +0100 Subject: [PATCH 2/2] chore: add PR description --- pr_description.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 pr_description.md diff --git a/pr_description.md b/pr_description.md new file mode 100644 index 00000000..61446325 --- /dev/null +++ b/pr_description.md @@ -0,0 +1,41 @@ +## Summary + +Implements four features across the auth and waitlist modules. + +### Issues resolved + +- Closes dupdab/dupdapp_stellar#589 — `POST /api/v1/auth/login`: bcrypt password comparison, JWT signing with configurable secret/expiry, returns `{ accessToken, refreshToken, merchant }` +- Closes dupdab/dupdapp_stellar#590 — `POST /api/v1/auth/refresh`: refresh token rotation, SHA-256 hash stored in DB, expired/used tokens rejected with 401 +- Closes dupdab/dupdapp_stellar#592 — `GET /api/v1/auth/verify-email?token=xxx`: sets `emailVerified` flag, 24-hour expiry, `POST /api/v1/auth/resend-verification` endpoint +- Closes dupdab/dupdapp_stellar#690 — `POST /api/v1/waitlist/join`: generates unique referral code per member, accepts `referralCode` on join, moves referrer up 5 queue positions atomically, rejects self-referrals with 400 + +### Files added + +``` +backend/ + src/ + app.module.ts + auth/ + auth.controller.ts — login, refresh, verify-email, resend-verification + auth.module.ts + auth.service.ts — all auth business logic + refresh-token.entity.ts + merchant/ + merchant.entity.ts — emailVerified, emailVerifyToken, emailVerifyExpiry + waitlist/ + waitlist.controller.ts + waitlist.entity.ts — referralCode, referralCount, position + waitlist.module.ts + waitlist.service.ts — join + referral position logic (atomic transaction) + package.json + tsconfig.json +``` + +### Key implementation notes + +- Refresh tokens stored as SHA-256 hashes — raw token never persisted +- Rotation enforced: each use of a refresh token deletes the old one and issues a new one +- Referral position shift runs inside a TypeORM transaction to prevent race conditions +- Email verification expiry is 24 hours; resend endpoint resets the token and expiry +- JWT payload includes `sub` (merchantId) and `email` per spec +- No new dependencies beyond what a standard NestJS/TypeORM project already uses