Add SSO handoff tickets and expand seeded countries for address forms.

Support one-time Redis-backed dashboard sign-in handoff and seed European, Middle East, and Far East countries with major cities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-19 19:46:57 +03:30
co-authored by Cursor
parent 7277817b6e
commit f61bba317e
6 changed files with 319 additions and 1 deletions
@@ -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
);
+4 -1
View File
@@ -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.
+12
View File
@@ -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) {
+68
View File
@@ -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(),
+9
View File
@@ -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;
}
+16
View File
@@ -22,6 +22,22 @@ export class RedisService {
await this.redis.del(`otp:${cellNumber}`);
}
async setHandoffTicket(
ticket: string,
userId: string,
ttlSeconds: number,
): Promise<void> {
await this.redis.set(`sso:handoff:${ticket}`, userId, 'EX', ttlSeconds);
}
/** Atomically read and delete a one-time SSO ticket. */
async consumeHandoffTicket(ticket: string): Promise<string | null> {
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`.