diff --git a/database/migrations/067_seed_countries_europe_middle_east_far_east.sql b/database/migrations/067_seed_countries_europe_middle_east_far_east.sql new file mode 100644 index 0000000..010116e --- /dev/null +++ b/database/migrations/067_seed_countries_europe_middle_east_far_east.sql @@ -0,0 +1,210 @@ +-- Seed European, Middle Eastern, and Far Eastern countries + major cities. +-- Skips rows when slug already exists (Iran/Iraq/Turkey/UAE from 053 remain). + +INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order) +SELECT NULL, 'country', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order +FROM ( + VALUES + -- Middle East (beyond 053) + ('عربستان سعودی', 'Saudi Arabia', '966', 'saudi-arabia', 5), + ('قطر', 'Qatar', '974', 'qatar', 6), + ('کویت', 'Kuwait', '965', 'kuwait', 7), + ('بحرین', 'Bahrain', '973', 'bahrain', 8), + ('عمان', 'Oman', '968', 'oman', 9), + ('اردن', 'Jordan', '962', 'jordan', 10), + ('لبنان', 'Lebanon', '961', 'lebanon', 11), + ('سوریه', 'Syria', '963', 'syria', 12), + ('فلسطین', 'Palestine', '970', 'palestine', 13), + ('اسرائیل', 'Israel', '972', 'israel', 14), + ('یمن', 'Yemen', '967', 'yemen', 15), + ('مصر', 'Egypt', '20', 'egypt', 16), + ('افغانستان', 'Afghanistan', '93', 'afghanistan', 17), + ('پاکستان', 'Pakistan', '92', 'pakistan', 18), + -- Europe + ('آلبانی', 'Albania', '355', 'albania', 100), + ('آندورا', 'Andorra', '376', 'andorra', 101), + ('اتریش', 'Austria', '43', 'austria', 102), + ('بلاروس', 'Belarus', '375', 'belarus', 103), + ('بلژیک', 'Belgium', '32', 'belgium', 104), + ('بوسنی و هرزگوین', 'Bosnia and Herzegovina', '387', 'bosnia-herzegovina', 105), + ('بلغارستان', 'Bulgaria', '359', 'bulgaria', 106), + ('کرواسی', 'Croatia', '385', 'croatia', 107), + ('قبرس', 'Cyprus', '357', 'cyprus', 108), + ('جمهوری چک', 'Czech Republic', '420', 'czech-republic', 109), + ('دانمارک', 'Denmark', '45', 'denmark', 110), + ('استونی', 'Estonia', '372', 'estonia', 111), + ('فنلاند', 'Finland', '358', 'finland', 112), + ('فرانسه', 'France', '33', 'france', 113), + ('آلمان', 'Germany', '49', 'germany', 114), + ('یونان', 'Greece', '30', 'greece', 115), + ('مجارستان', 'Hungary', '36', 'hungary', 116), + ('ایسلند', 'Iceland', '354', 'iceland', 117), + ('ایرلند', 'Ireland', '353', 'ireland', 118), + ('ایتالیا', 'Italy', '39', 'italy', 119), + ('کوزوو', 'Kosovo', '383', 'kosovo', 120), + ('لتونی', 'Latvia', '371', 'latvia', 121), + ('لیتوانی', 'Lithuania', '370', 'lithuania', 122), + ('لوکزامبورگ', 'Luxembourg', '352', 'luxembourg', 123), + ('مالت', 'Malta', '356', 'malta', 124), + ('مولداوی', 'Moldova', '373', 'moldova', 125), + ('موناکو', 'Monaco', '377', 'monaco', 126), + ('مونته‌نگرو', 'Montenegro', '382', 'montenegro', 127), + ('هلند', 'Netherlands', '31', 'netherlands', 128), + ('مقدونیه شمالی', 'North Macedonia', '389', 'north-macedonia', 129), + ('نروژ', 'Norway', '47', 'norway', 130), + ('لهستان', 'Poland', '48', 'poland', 131), + ('پرتغال', 'Portugal', '351', 'portugal', 132), + ('رومانی', 'Romania', '40', 'romania', 133), + ('روسیه', 'Russia', '7', 'russia', 134), + ('سان مارینو', 'San Marino', '378', 'san-marino', 135), + ('صربستان', 'Serbia', '381', 'serbia', 136), + ('اسلواکی', 'Slovakia', '421', 'slovakia', 137), + ('اسلوونی', 'Slovenia', '386', 'slovenia', 138), + ('اسپانیا', 'Spain', '34', 'spain', 139), + ('سوئد', 'Sweden', '46', 'sweden', 140), + ('سوئیس', 'Switzerland', '41', 'switzerland', 141), + ('اوکراین', 'Ukraine', '380', 'ukraine', 142), + ('بریتانیا', 'United Kingdom', '44', 'united-kingdom', 143), + -- Far East + ('چین', 'China', '86', 'china', 200), + ('ژاپن', 'Japan', '81', 'japan', 201), + ('کره جنوبی', 'South Korea', '82', 'south-korea', 202), + ('تایوان', 'Taiwan', '886', 'taiwan', 203), + ('هنگ‌کنگ', 'Hong Kong', '852', 'hong-kong', 204), + ('ماکائو', 'Macau', '853', 'macau', 205), + ('مغولستان', 'Mongolia', '976', 'mongolia', 206), + ('کره شمالی', 'North Korea', '850', 'north-korea', 207), + ('سنگاپور', 'Singapore', '65', 'singapore', 208), + ('مالزی', 'Malaysia', '60', 'malaysia', 209), + ('تایلند', 'Thailand', '66', 'thailand', 210), + ('ویتنام', 'Vietnam', '84', 'vietnam', 211), + ('اندونزی', 'Indonesia', '62', 'indonesia', 212), + ('فیلیپین', 'Philippines', '63', 'philippines', 213), + ('هند', 'India', '91', 'india', 214), + ('بنگلادش', 'Bangladesh', '880', 'bangladesh', 215), + ('سری‌لانکا', 'Sri Lanka', '94', 'sri-lanka', 216), + ('نپال', 'Nepal', '977', 'nepal', 217), + ('میانمار', 'Myanmar', '95', 'myanmar', 218), + ('کامبوج', 'Cambodia', '855', 'cambodia', 219), + ('لائوس', 'Laos', '856', 'laos', 220), + ('برونئی', 'Brunei', '673', 'brunei', 221) +) AS v(name_fa, name_en, landline_code, slug, sort_order) +WHERE NOT EXISTS ( + SELECT 1 FROM cities c WHERE c.slug = v.slug +); + +INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order) +SELECT c.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order +FROM cities c +JOIN ( + VALUES + -- Middle East + ('saudi-arabia', 'ریاض', 'Riyadh', '11', 'riyadh', 1), + ('saudi-arabia', 'جده', 'Jeddah', '12', 'jeddah', 2), + ('saudi-arabia', 'مکه', 'Mecca', '12', 'mecca', 3), + ('qatar', 'دوحه', 'Doha', '4', 'doha', 1), + ('kuwait', 'کویت', 'Kuwait City', '2', 'kuwait-city', 1), + ('bahrain', 'منامه', 'Manama', '17', 'manama', 1), + ('oman', 'مسقط', 'Muscat', '24', 'muscat', 1), + ('jordan', 'امان', 'Amman', '6', 'amman', 1), + ('lebanon', 'بیروت', 'Beirut', '1', 'beirut', 1), + ('syria', 'دمشق', 'Damascus', '11', 'damascus', 1), + ('palestine', 'رام‌الله', 'Ramallah', '2', 'ramallah', 1), + ('israel', 'تل‌آویو', 'Tel Aviv', '3', 'tel-aviv', 1), + ('israel', 'اورشلیم', 'Jerusalem', '2', 'jerusalem', 2), + ('yemen', 'صنعا', 'Sanaa', '1', 'sanaa', 1), + ('egypt', 'قاهره', 'Cairo', '2', 'cairo', 1), + ('egypt', 'اسکندریه', 'Alexandria', '3', 'alexandria', 2), + ('afghanistan', 'کابل', 'Kabul', '20', 'kabul', 1), + ('pakistan', 'اسلام‌آباد', 'Islamabad', '51', 'islamabad', 1), + ('pakistan', 'کراچی', 'Karachi', '21', 'karachi', 2), + ('pakistan', 'لاهور', 'Lahore', '42', 'lahore', 3), + -- Europe + ('albania', 'تیرانا', 'Tirana', '4', 'tirana', 1), + ('andorra', 'آندورا لا ولا', 'Andorra la Vella', '7', 'andorra-la-vella', 1), + ('austria', 'وین', 'Vienna', '1', 'vienna', 1), + ('belarus', 'مینسک', 'Minsk', '17', 'minsk', 1), + ('belgium', 'بروکسل', 'Brussels', '2', 'brussels', 1), + ('bosnia-herzegovina', 'Sarajevo', 'Sarajevo', '33', 'sarajevo', 1), + ('bulgaria', 'صوفیه', 'Sofia', '2', 'sofia', 1), + ('croatia', 'Zagreb', 'Zagreb', '1', 'zagreb', 1), + ('cyprus', 'نیکوزیا', 'Nicosia', '22', 'nicosia', 1), + ('czech-republic', 'پراگ', 'Prague', '2', 'prague', 1), + ('denmark', 'Copenhagen', 'Copenhagen', '3', 'copenhagen', 1), + ('estonia', 'تالین', 'Tallinn', '6', 'tallinn', 1), + ('finland', 'Helsinki', 'Helsinki', '9', 'helsinki', 1), + ('france', 'پاریس', 'Paris', '1', 'paris', 1), + ('france', 'لیون', 'Lyon', '4', 'lyon', 2), + ('germany', 'Berlin', 'Berlin', '30', 'berlin', 1), + ('germany', 'Munich', 'Munich', '89', 'munich', 2), + ('greece', 'Athens', 'Athens', '21', 'athens', 1), + ('hungary', 'Budapest', 'Budapest', '1', 'budapest', 1), + ('iceland', 'reykjavik', 'Reykjavik', '5', 'reykjavik', 1), + ('ireland', 'Dublin', 'Dublin', '1', 'dublin', 1), + ('italy', 'رم', 'Rome', '06', 'rome', 1), + ('italy', 'میلان', 'Milan', '02', 'milan', 2), + ('kosovo', 'Pristina', 'Pristina', '38', 'pristina', 1), + ('latvia', 'riga', 'Riga', '6', 'riga', 1), + ('lithuania', 'vilnius', 'Vilnius', '5', 'vilnius', 1), + ('luxembourg', 'لوکزامبورگ', 'Luxembourg', '2', 'luxembourg-city', 1), + ('malta', 'valletta', 'Valletta', '21', 'valletta', 1), + ('moldova', 'chisinau', 'Chisinau', '22', 'chisinau', 1), + ('monaco', 'موناکو', 'Monaco', '9', 'monaco-city', 1), + ('montenegro', 'podgorica', 'Podgorica', '20', 'podgorica', 1), + ('netherlands', 'Amsterdam', 'Amsterdam', '20', 'amsterdam', 1), + ('north-macedonia', 'skopje', 'Skopje', '2', 'skopje', 1), + ('norway', 'oslo', 'Oslo', '2', 'oslo', 1), + ('poland', 'warsaw', 'Warsaw', '22', 'warsaw', 1), + ('portugal', 'lisbon', 'Lisbon', '21', 'lisbon', 1), + ('romania', 'bucharest', 'Bucharest', '21', 'bucharest', 1), + ('russia', 'moscow', 'Moscow', '495', 'moscow', 1), + ('russia', 'saint-petersburg', 'Saint Petersburg', '812', 'saint-petersburg', 2), + ('san-marino', 'san-marino-city', 'San Marino', '549', 'san-marino-city', 1), + ('serbia', 'belgrade', 'Belgrade', '11', 'belgrade', 1), + ('slovakia', 'bratislava', 'Bratislava', '2', 'bratislava', 1), + ('slovenia', 'ljubljana', 'Ljubljana', '1', 'ljubljana', 1), + ('spain', 'madrid', 'Madrid', '91', 'madrid', 1), + ('spain', 'barcelona', 'Barcelona', '93', 'barcelona', 2), + ('sweden', 'stockholm', 'Stockholm', '8', 'stockholm', 1), + ('switzerland', 'zurich', 'Zurich', '44', 'zurich', 1), + ('switzerland', 'geneva', 'Geneva', '22', 'geneva', 2), + ('ukraine', 'kyiv', 'Kyiv', '44', 'kyiv', 1), + ('united-kingdom', 'london', 'London', '20', 'london', 1), + ('united-kingdom', 'manchester', 'Manchester', '161', 'manchester', 2), + -- Far East + ('china', 'پکن', 'Beijing', '10', 'beijing', 1), + ('china', 'Shanghai', 'Shanghai', '21', 'shanghai', 2), + ('china', 'guangzhou', 'Guangzhou', '20', 'guangzhou', 3), + ('china', 'shenzhen', 'Shenzhen', '755', 'shenzhen', 4), + ('japan', 'Tokyo', 'Tokyo', '3', 'tokyo', 1), + ('japan', 'osaka', 'Osaka', '6', 'osaka', 2), + ('japan', 'kyoto', 'Kyoto', '75', 'kyoto', 3), + ('south-korea', 'سئول', 'Seoul', '2', 'seoul', 1), + ('south-korea', 'busan', 'Busan', '51', 'busan', 2), + ('taiwan', 'taipei', 'Taipei', '2', 'taipei', 1), + ('hong-kong', 'هنگ‌کنگ', 'Hong Kong', '2', 'hong-kong-city', 1), + ('macau', 'ماکائو', 'Macau', '28', 'macau-city', 1), + ('mongolia', 'ulaanbaatar', 'Ulaanbaatar', '11', 'ulaanbaatar', 1), + ('north-korea', 'pyongyang', 'Pyongyang', '2', 'pyongyang', 1), + ('singapore', 'سنگاپور', 'Singapore', '6', 'singapore-city', 1), + ('malaysia', 'kuala-lumpur', 'Kuala Lumpur', '3', 'kuala-lumpur', 1), + ('thailand', 'bangkok', 'Bangkok', '2', 'bangkok', 1), + ('vietnam', 'hanoi', 'Hanoi', '24', 'hanoi', 1), + ('vietnam', 'ho-chi-minh-city', 'Ho Chi Minh City', '28', 'ho-chi-minh-city', 2), + ('indonesia', 'jakarta', 'Jakarta', '21', 'jakarta', 1), + ('philippines', 'manila', 'Manila', '2', 'manila', 1), + ('india', 'new-delhi', 'New Delhi', '11', 'new-delhi', 1), + ('india', 'mumbai', 'Mumbai', '22', 'mumbai', 2), + ('india', 'bangalore', 'Bangalore', '80', 'bangalore', 3), + ('bangladesh', 'dhaka', 'Dhaka', '2', 'dhaka', 1), + ('sri-lanka', 'colombo', 'Colombo', '11', 'colombo', 1), + ('nepal', 'kathmandu', 'Kathmandu', '1', 'kathmandu', 1), + ('myanmar', 'yangon', 'Yangon', '1', 'yangon', 1), + ('cambodia', 'phnom-penh', 'Phnom Penh', '23', 'phnom-penh', 1), + ('laos', 'vientiane', 'Vientiane', '21', 'vientiane', 1), + ('brunei', 'bandar-seri-begawan', 'Bandar Seri Begawan', '2', 'bandar-seri-begawan', 1) +) AS v(country_slug, name_fa, name_en, landline_code, slug, sort_order) + ON c.slug = v.country_slug AND c.level = 'country' +WHERE NOT EXISTS ( + SELECT 1 FROM cities x WHERE x.slug = v.slug +); diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 5cc1a46..917b384 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -1,7 +1,7 @@ # Meshkee CMS API — Project Context > Living reference for developers and AI assistants working on this codebase. -> Last updated: August 18, 2026 +> Last updated: August 19, 2026 ## What This Project Is @@ -260,6 +260,7 @@ All routes are prefixed with `/api/v1`. | POST | `/auth/refresh` | Refresh token | | POST | `/auth/send-otp` | Send OTP (Redis-backed) | | POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) | +| POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) | | GET | `/tenants/:host` | Resolve business from domain | | GET | `/tenants/:host/store-specials` | Active store specials | | GET | `/tenants/:host/website/category-groups` | Homepage category rows | @@ -276,6 +277,7 @@ All routes are prefixed with `/api/v1`. | GET | `/auth/me` | Any user | | PATCH | `/auth/profile` | Any user | | POST | `/auth/change-password` | Any user | +| POST | `/auth/handoff` | Staff/owner/super-admin; issues a 60s one-time SSO ticket | | GET | `/roles?scope=global\|team` | Super admin / team.read | | GET | `/business-categories` | Super admin or `business_categories.read` | @@ -419,6 +421,7 @@ Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` f - `POST /auth/login-otp` → passwordless login (consumes OTP, verifies cell, returns tokens) - `POST /auth/reset-password` → forgot password (OTP + `newPassword`, verifies cell) - Password login (`POST /auth/login`) rejects unverified cells when SMS is enabled +- Dashboard SSO: `POST /auth/handoff` (JWT) stores a one-time Redis ticket (`sso:handoff:{ticket}`, 60s). Business dashboard calls `POST /auth/handoff/consume` and receives tokens. - JWT payload: `sub`, `cellNumber`, `roles`, `dashboard`, `type` - SMS provider: Gama (`sms.igama.ir`) SendQuick via service shortcode (`SMS_GAMA_*`) - Partner gateway (external sites like Balout): `POST /api/v1/public/sms/send` with `X-Api-Key` + body `{ domain, to, message }`; partners configured in `SMS_PARTNERS` (`domain:apiKey` pairs). Rate limits: 30/partner/min and 5/destination/min. Not part of storefront website-api docs. diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index a3b2b95..15996ae 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -13,6 +13,7 @@ import { CurrentUser } from './decorators/current-user.decorator'; import { ChangePasswordDto } from './dto/change-password.dto'; import { LoginDto } from './dto/login.dto'; import { LoginOtpDto } from './dto/login-otp.dto'; +import { ConsumeHandoffDto } from './dto/consume-handoff.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; import { RegisterDto } from './dto/register.dto'; import { ResetPasswordDto } from './dto/reset-password.dto'; @@ -60,6 +61,17 @@ export class AuthController { return this.authService.refresh(dto.refreshToken); } + @Post('handoff') + @UseGuards(JwtAuthGuard) + createHandoff(@CurrentUser() user: AuthUser) { + return this.authService.createHandoff(user); + } + + @Post('handoff/consume') + consumeHandoff(@Body() dto: ConsumeHandoffDto) { + return this.authService.consumeHandoff(dto.ticket); + } + @Get('me') @UseGuards(JwtAuthGuard) me(@CurrentUser() user: AuthUser) { diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 9e9586a..7c090ac 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + ForbiddenException, HttpException, Injectable, ServiceUnavailableException, @@ -9,6 +10,7 @@ import { import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; +import { randomBytes } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { RedisService } from '../redis/redis.service'; import { TenantService } from '../tenant/tenant.service'; @@ -28,6 +30,9 @@ import { parseUserProfile } from './profile.util'; import { SmsService } from './sms.service'; const OTP_TTL_SECONDS = 300; +const HANDOFF_TTL_SECONDS = 60; +const HANDOFF_RATE_LIMIT = 10; +const HANDOFF_RATE_WINDOW_SECONDS = 60; @Injectable() export class AuthService { @@ -216,6 +221,62 @@ export class AuthService { }; } + async createHandoff(user: AuthUser) { + if (!this.canAccessBusinessDashboard(user)) { + throw new ForbiddenException( + 'This account cannot open the business dashboard.', + ); + } + + const allowed = await this.redis.incrementWithLimit( + `sso:handoff:rate:${user.id.toString()}`, + HANDOFF_RATE_LIMIT, + HANDOFF_RATE_WINDOW_SECONDS, + ); + if (!allowed) { + throw new HttpException('Too many dashboard handoff requests. Try again shortly.', 429); + } + + const ticket = randomBytes(32).toString('hex'); + await this.redis.setHandoffTicket( + ticket, + user.id.toString(), + HANDOFF_TTL_SECONDS, + ); + + return { + ticket, + expiresInSeconds: HANDOFF_TTL_SECONDS, + }; + } + + async consumeHandoff(ticket: string) { + const userId = await this.redis.consumeHandoffTicket(ticket); + if (!userId) { + throw new UnauthorizedException('Invalid or expired dashboard handoff'); + } + + const authUser = await this.getAuthUser(BigInt(userId)); + if (!authUser || !this.canAccessBusinessDashboard(authUser)) { + throw new ForbiddenException( + 'This account cannot access the business dashboard.', + ); + } + + await this.prisma.user.update({ + where: { id: authUser.id }, + data: { lastLoginAt: new Date() }, + }); + + const tokens = await this.issueTokens(authUser); + + return { + message: 'Login successful', + user: this.serializeUser(authUser), + ...tokens, + }; + } + async me(user: AuthUser) { return { user: this.serializeUser(user) }; } @@ -526,6 +587,13 @@ export class AuthService { return 'customer'; } + private canAccessBusinessDashboard(user: AuthUser): boolean { + if (user.roles.includes('super_admin')) { + return true; + } + return user.businesses.length > 0; + } + private async issueTokens(user: AuthUser) { const accessPayload: AuthJwtPayload = { sub: user.id.toString(), diff --git a/src/auth/dto/consume-handoff.dto.ts b/src/auth/dto/consume-handoff.dto.ts new file mode 100644 index 0000000..f0a3605 --- /dev/null +++ b/src/auth/dto/consume-handoff.dto.ts @@ -0,0 +1,9 @@ +import { IsString, Matches } from 'class-validator'; + +export class ConsumeHandoffDto { + @IsString() + @Matches(/^[a-f0-9]{64}$/, { + message: 'ticket must be a 64-character hex string', + }) + ticket!: string; +} diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index df720d4..8119f31 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -22,6 +22,22 @@ export class RedisService { await this.redis.del(`otp:${cellNumber}`); } + async setHandoffTicket( + ticket: string, + userId: string, + ttlSeconds: number, + ): Promise { + await this.redis.set(`sso:handoff:${ticket}`, userId, 'EX', ttlSeconds); + } + + /** Atomically read and delete a one-time SSO ticket. */ + async consumeHandoffTicket(ticket: string): Promise { + const key = `sso:handoff:${ticket}`; + const result = await this.redis.multi().get(key).del(key).exec(); + const value = result?.[0]?.[1]; + return typeof value === 'string' ? value : null; + } + /** * Sliding fixed-window counter. Returns true if the call is within `limit` * for the given key over `windowSeconds`.