diff --git a/.agents/README.md b/.agents/README.md index 61bd34e..73d343d 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -258,37 +258,37 @@ If you change your mind mid-project: ### 📊 Current Status: UAT Ready (2026-03-11) -| Area | Status | -|------|--------| -| Backend | ✅ 18 Modules, Production Ready | -| Frontend | ✅ 100% Complete | -| Database | ✅ Schema v1.8.0 Stable | -| Documentation | ✅ **10/10 Gaps Closed** | -| AI Migration | 🔄 Pre-migration Setup (n8n + Ollama) | -| UAT | 🔄 In Progress | -| Deployment | 📋 Pending Go-Live | +| Area | Status | +| ------------- | ------------------------------------- | +| Backend | ✅ 18 Modules, Production Ready | +| Frontend | ✅ 100% Complete | +| Database | ✅ Schema v1.8.0 Stable | +| Documentation | ✅ **10/10 Gaps Closed** | +| AI Migration | 🔄 Pre-migration Setup (n8n + Ollama) | +| UAT | 🔄 In Progress | +| Deployment | 📋 Pending Go-Live | ### 📁 Key Spec Files (Always Check Before Writing Code) -| เอกสาร | Path | ใช้เมื่อ | -|--------|------|--------| -| Schema Tables | `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | ก่อนเขียน Query | -| Data Dictionary | `specs/03-Data-and-Storage/03-01-data-dictionary.md` | ตรวจ Business Rules | -| Edge Cases | `specs/01-Requirements/01-06-edge-cases-and-rules.md` | 37 Rules | -| Migration Scope | `specs/03-Data-and-Storage/03-06-migration-business-scope.md` | Migration Bot | -| Release Policy | `specs/04-Infrastructure-OPS/04-08-release-management-policy.md` | ก่อน Deploy | -| UAT Criteria | `specs/01-Requirements/01-05-acceptance-criteria.md` | ตรวจ Feature | +| เอกสาร | Path | ใช้เมื่อ | +| --------------- | ---------------------------------------------------------------- | ------------------- | +| Schema Tables | `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | ก่อนเขียน Query | +| Data Dictionary | `specs/03-Data-and-Storage/03-01-data-dictionary.md` | ตรวจ Business Rules | +| Edge Cases | `specs/01-Requirements/01-06-edge-cases-and-rules.md` | 37 Rules | +| Migration Scope | `specs/03-Data-and-Storage/03-06-migration-business-scope.md` | Migration Bot | +| Release Policy | `specs/04-Infrastructure-OPS/04-08-release-management-policy.md` | ก่อน Deploy | +| UAT Criteria | `specs/01-Requirements/01-05-acceptance-criteria.md` | ตรวจ Feature | ### ⚡ Project-Specific Workflow Cheatsheet -| Task | Workflow / Command | Notes | -|------|--------------------|-------| -| Create Backend Module | `/create-backend-module` | Scaffolds NestJS module | -| Create Frontend Page | `/create-frontend-page` | Next.js App Router page | -| Schema Change | `/schema-change` | ADR-009: No migrations | -| Deploy | `/deploy` | Blue-Green via Gitea CI/CD | -| UAT Feature Check | `/11-speckit-validate` | vs `01-05-acceptance-criteria.md` | -| Security Audit | `@speckit-security-audit` | OWASP + CASL + ClamAV | +| Task | Workflow / Command | Notes | +| --------------------- | ------------------------- | --------------------------------- | +| Create Backend Module | `/create-backend-module` | Scaffolds NestJS module | +| Create Frontend Page | `/create-frontend-page` | Next.js App Router page | +| Schema Change | `/schema-change` | ADR-009: No migrations | +| Deploy | `/deploy` | Blue-Green via Gitea CI/CD | +| UAT Feature Check | `/11-speckit-validate` | vs `01-05-acceptance-criteria.md` | +| Security Audit | `@speckit-security-audit` | OWASP + CASL + ClamAV | ### 🚫 Critical Forbidden Actions diff --git a/.agents/skills/nestjs-best-practices/AGENTS.md b/.agents/skills/nestjs-best-practices/AGENTS.md index d14ec24..49234af 100644 --- a/.agents/skills/nestjs-best-practices/AGENTS.md +++ b/.agents/skills/nestjs-best-practices/AGENTS.md @@ -67,9 +67,10 @@ Comprehensive best practices and architecture guide for NestJS applications, des - 9.2 [Use Message and Event Patterns Correctly](#92-use-message-and-event-patterns-correctly) - 9.3 [Use Message Queues for Background Jobs](#93-use-message-queues-for-background-jobs) 10. [DevOps & Deployment](#10-devops-deployment) — **LOW-MEDIUM** - - 10.1 [Implement Graceful Shutdown](#101-implement-graceful-shutdown) - - 10.2 [Use ConfigModule for Environment Configuration](#102-use-configmodule-for-environment-configuration) - - 10.3 [Use Structured Logging](#103-use-structured-logging) + +- 10.1 [Implement Graceful Shutdown](#101-implement-graceful-shutdown) +- 10.2 [Use ConfigModule for Environment Configuration](#102-use-configmodule-for-environment-configuration) +- 10.3 [Use Structured Logging](#103-use-structured-logging) --- @@ -390,7 +391,7 @@ export class UserAndOrderService { private userRepo: UserRepository, private orderRepo: OrderRepository, private mailer: MailService, - private payment: PaymentService, + private payment: PaymentService ) {} async createUser(dto: CreateUserDto) { @@ -461,7 +462,7 @@ export class OrdersController { constructor( private orders: OrdersService, private payment: PaymentService, - private notifications: NotificationService, + private notifications: NotificationService ) {} @Post() @@ -495,7 +496,7 @@ export class OrdersService { private emailService: EmailService, private analyticsService: AnalyticsService, private notificationService: NotificationService, - private loyaltyService: LoyaltyService, + private loyaltyService: LoyaltyService ) {} async createOrder(dto: CreateOrderDto): Promise { @@ -526,7 +527,7 @@ export class OrderCreatedEvent { public readonly orderId: string, public readonly userId: string, public readonly items: OrderItem[], - public readonly total: number, + public readonly total: number ) {} } @@ -535,17 +536,14 @@ export class OrderCreatedEvent { export class OrdersService { constructor( private eventEmitter: EventEmitter2, - private repo: Repository, + private repo: Repository ) {} async createOrder(dto: CreateOrderDto): Promise { const order = await this.repo.save(dto); // Emit event - no knowledge of consumers - this.eventEmitter.emit( - 'order.created', - new OrderCreatedEvent(order.id, order.userId, order.items, order.total), - ); + this.eventEmitter.emit('order.created', new OrderCreatedEvent(order.id, order.userId, order.items, order.total)); return order; } @@ -596,9 +594,7 @@ Create custom repositories to encapsulate complex queries and database logic. Th // Complex queries in services @Injectable() export class UsersService { - constructor( - @InjectRepository(User) private repo: Repository, - ) {} + constructor(@InjectRepository(User) private repo: Repository) {} async findActiveWithOrders(minOrders: number): Promise { // Complex query logic mixed with business logic @@ -623,9 +619,7 @@ export class UsersService { // Custom repository with encapsulated queries @Injectable() export class UsersRepository { - constructor( - @InjectRepository(User) private repo: Repository, - ) {} + constructor(@InjectRepository(User) private repo: Repository) {} async findById(id: string): Promise { return this.repo.findOne({ where: { id } }); @@ -735,7 +729,7 @@ export class OrdersService { constructor( private usersService: UsersService, private inventoryService: InventoryService, - private paymentService: PaymentService, + private paymentService: PaymentService ) {} async createOrder(dto: CreateOrderDto): Promise { @@ -810,14 +804,14 @@ interface NotificationService { @Injectable() export class OrdersService { constructor( - private notifications: NotificationService, // Depends on 8 methods, uses 1 + private notifications: NotificationService // Depends on 8 methods, uses 1 ) {} async confirmOrder(order: Order): Promise { await this.notifications.sendEmail( order.customer.email, 'Order Confirmed', - `Your order ${order.id} has been confirmed.`, + `Your order ${order.id} has been confirmed.` ); } } @@ -825,12 +819,12 @@ export class OrdersService { // Testing is painful - must mock unused methods const mockNotificationService = { sendEmail: jest.fn(), - sendSms: jest.fn(), // Never used, but required - sendPush: jest.fn(), // Never used, but required - sendSlack: jest.fn(), // Never used, but required - logNotification: jest.fn(), // Never used, but required + sendSms: jest.fn(), // Never used, but required + sendPush: jest.fn(), // Never used, but required + sendSlack: jest.fn(), // Never used, but required + logNotification: jest.fn(), // Never used, but required getDeliveryStatus: jest.fn(), // Never used, but required - retryFailed: jest.fn(), // Never used, but required + retryFailed: jest.fn(), // Never used, but required scheduleNotification: jest.fn(), // Never used, but required }; ``` @@ -887,14 +881,14 @@ export class SendGridEmailService implements EmailSender { @Injectable() export class OrdersService { constructor( - @Inject(EMAIL_SENDER) private emailSender: EmailSender, // Minimal dependency + @Inject(EMAIL_SENDER) private emailSender: EmailSender // Minimal dependency ) {} async confirmOrder(order: Order): Promise { await this.emailSender.sendEmail( order.customer.email, 'Order Confirmed', - `Your order ${order.id} has been confirmed.`, + `Your order ${order.id} has been confirmed.` ); } } @@ -932,7 +926,7 @@ type MultiChannelSender = EmailSender & SmsSender & PushSender; export class AlertService { constructor( @Inject(MULTI_CHANNEL_SENDER) - private sender: EmailSender & SmsSender, + private sender: EmailSender & SmsSender ) {} async sendCriticalAlert(user: User, message: string): Promise { @@ -1123,9 +1117,7 @@ export class OrdersService { ```typescript // Shared test suite that any implementation must pass -function testPaymentGatewayContract( - createGateway: () => PaymentGateway, -) { +function testPaymentGatewayContract(createGateway: () => PaymentGateway) { describe('PaymentGateway contract', () => { let gateway: PaymentGateway; @@ -1142,13 +1134,11 @@ function testPaymentGatewayContract( }); it('throws InvalidCurrencyException for unsupported currency', async () => { - await expect(gateway.charge(1000, 'INVALID')) - .rejects.toThrow(InvalidCurrencyException); + await expect(gateway.charge(1000, 'INVALID')).rejects.toThrow(InvalidCurrencyException); }); it('throws TransactionNotFoundException for invalid refund', async () => { - await expect(gateway.refund('nonexistent')) - .rejects.toThrow(TransactionNotFoundException); + await expect(gateway.refund('nonexistent')).rejects.toThrow(TransactionNotFoundException); }); }); } @@ -1204,7 +1194,7 @@ export class UsersService { export class UsersService { constructor( private readonly userRepo: UserRepository, - @Inject('CONFIG') private readonly config: ConfigType, + @Inject('CONFIG') private readonly config: ConfigType ) {} async findAll(): Promise { @@ -1359,7 +1349,9 @@ interface PaymentGateway { @Injectable() export class StripeService implements PaymentGateway { - charge(amount: number) { /* ... */ } + charge(amount: number) { + /* ... */ + } } @Injectable() @@ -1398,9 +1390,7 @@ export class MockPaymentService implements PaymentGateway { providers: [ { provide: PAYMENT_GATEWAY, - useClass: process.env.NODE_ENV === 'test' - ? MockPaymentService - : StripeService, + useClass: process.env.NODE_ENV === 'test' ? MockPaymentService : StripeService, }, ], exports: [PAYMENT_GATEWAY], @@ -1410,9 +1400,7 @@ export class PaymentModule {} // Injection @Injectable() export class OrdersService { - constructor( - @Inject(PAYMENT_GATEWAY) private payment: PaymentGateway, - ) {} + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} async createOrder(dto: CreateOrderDto) { await this.payment.charge(dto.amount); @@ -1654,7 +1642,7 @@ export class UsersController { export class EntityNotFoundException extends Error { constructor( public readonly entity: string, - public readonly id: string, + public readonly id: string ) { super(`${entity} with ID "${id}" not found`); } @@ -1773,20 +1761,11 @@ export class AllExceptionsFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); - const status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; - const message = - exception instanceof HttpException - ? exception.message - : 'Internal server error'; + const message = exception instanceof HttpException ? exception.message : 'Internal server error'; - this.logger.error( - `${request.method} ${request.url}`, - exception instanceof Error ? exception.stack : exception, - ); + this.logger.error(`${request.method} ${request.url}`, exception instanceof Error ? exception.stack : exception); response.status(status).json({ statusCode: status, @@ -1798,10 +1777,7 @@ export class AllExceptionsFilter implements ExceptionFilter { } // Register globally in main.ts -app.useGlobalFilters( - new AllExceptionsFilter(app.get(Logger)), - new DomainExceptionFilter(), -); +app.useGlobalFilters(new AllExceptionsFilter(app.get(Logger)), new DomainExceptionFilter()); // Or via module @Module({ @@ -1931,7 +1907,7 @@ export class AuthService { export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private config: ConfigService, - private usersService: UsersService, + private usersService: UsersService ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), @@ -2271,15 +2247,12 @@ export class AdminController { export class JwtAuthGuard implements CanActivate { constructor( private jwtService: JwtService, - private reflector: Reflector, + private reflector: Reflector ) {} async canActivate(context: ExecutionContext): Promise { // Check for @Public() decorator - const isPublic = this.reflector.getAllAndOverride('isPublic', [ - context.getHandler(), - context.getClass(), - ]); + const isPublic = this.reflector.getAllAndOverride('isPublic', [context.getHandler(), context.getClass()]); if (isPublic) return true; const request = context.switchToHttp().getRequest(); @@ -2309,10 +2282,7 @@ export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride('roles', [ - context.getHandler(), - context.getClass(), - ]); + const requiredRoles = this.reflector.getAllAndOverride('roles', [context.getHandler(), context.getClass()]); if (!requiredRoles) return true; @@ -2387,9 +2357,9 @@ export class UsersController { // DTOs without validation decorators export class CreateUserDto { - name: string; // No validation - email: string; // Could be "not-an-email" - age: number; // Could be "abc" or -999 + name: string; // No validation + email: string; // Could be "not-an-email" + age: number; // Could be "abc" or -999 } ``` @@ -2402,13 +2372,13 @@ async function bootstrap() { app.useGlobalPipes( new ValidationPipe({ - whitelist: true, // Strip unknown properties - forbidNonWhitelisted: true, // Throw on unknown properties - transform: true, // Auto-transform to DTO types + whitelist: true, // Strip unknown properties + forbidNonWhitelisted: true, // Throw on unknown properties + transform: true, // Auto-transform to DTO types transformOptions: { enableImplicitConversion: true, }, - }), + }) ); await app.listen(3000); @@ -2573,7 +2543,7 @@ export class DatabaseService implements OnModuleInit { export class CacheWarmerService implements OnApplicationBootstrap { constructor( private cache: CacheService, - private products: ProductsService, + private products: ProductsService ) {} async onApplicationBootstrap(): Promise { @@ -2697,10 +2667,7 @@ export class ModuleLoaderService { constructor(private lazyModuleLoader: LazyModuleLoader) {} - async load( - key: string, - importFn: () => Promise<{ default: Type } | Type>, - ): Promise { + async load(key: string, importFn: () => Promise<{ default: Type } | Type>): Promise { if (!this.loadedModules.has(key)) { const module = await importFn(); const moduleType = 'default' in module ? module.default : module; @@ -2915,9 +2882,7 @@ export class UsersService { imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ - stores: [ - new KeyvRedis(config.get('REDIS_URL')), - ], + stores: [new KeyvRedis(config.get('REDIS_URL'))], ttl: 60 * 1000, // Default 60s }), }), @@ -2930,7 +2895,7 @@ export class AppModule {} export class ProductsService { constructor( @Inject(CACHE_MANAGER) private cache: Cache, - private productsRepo: ProductRepository, + private productsRepo: ProductRepository ) {} async getPopular(): Promise { @@ -2981,10 +2946,7 @@ export class CacheInvalidationService { @OnEvent('product.updated') @OnEvent('product.deleted') async invalidateProductCaches(event: ProductEvent) { - await Promise.all([ - this.cache.del('products:popular'), - this.cache.del(`product:${event.productId}`), - ]); + await Promise.all([this.cache.del('products:popular'), this.cache.del(`product:${event.productId}`)]); } } ``` @@ -3055,7 +3017,7 @@ describe('UsersController (e2e)', () => { whitelist: true, transform: true, forbidNonWhitelisted: true, - }), + }) ); await app.init(); @@ -3091,9 +3053,7 @@ describe('UsersController (e2e)', () => { describe('/users/:id (GET)', () => { it('should return 404 for non-existent user', () => { - return request(app.getHttpServer()) - .get('/users/non-existent-id') - .expect(404); + return request(app.getHttpServer()).get('/users/non-existent-id').expect(404); }); }); }); @@ -3121,9 +3081,7 @@ describe('Protected Routes (e2e)', () => { }); it('should return 401 without token', () => { - return request(app.getHttpServer()) - .get('/users/me') - .expect(401); + return request(app.getHttpServer()).get('/users/me').expect(401); }); it('should return user profile with valid token', () => { @@ -3254,9 +3212,7 @@ describe('WeatherService', () => { }); it('should handle API timeout', async () => { - httpService.get.mockReturnValue( - throwError(() => new Error('ETIMEDOUT')), - ); + httpService.get.mockReturnValue(throwError(() => new Error('ETIMEDOUT'))); await expect(service.getWeather('NYC')).rejects.toThrow('Weather service unavailable'); }); @@ -3265,7 +3221,7 @@ describe('WeatherService', () => { httpService.get.mockReturnValue( throwError(() => ({ response: { status: 429, data: { message: 'Rate limited' } }, - })), + })) ); await expect(service.getWeather('NYC')).rejects.toThrow(TooManyRequestsException); @@ -3287,10 +3243,7 @@ describe('UsersService', () => { }; const module = await Test.createTestingModule({ - providers: [ - UsersService, - { provide: getRepositoryToken(User), useValue: mockRepo }, - ], + providers: [UsersService, { provide: getRepositoryToken(User), useValue: mockRepo }], }).compile(); service = module.get(UsersService); @@ -3433,9 +3386,7 @@ describe('UsersService', () => { it('should throw on duplicate email', async () => { repo.findOne.mockResolvedValue({ id: '1', email: 'test@test.com' }); - await expect( - service.create({ name: 'Test', email: 'test@test.com' }), - ).rejects.toThrow(ConflictException); + await expect(service.create({ name: 'Test', email: 'test@test.com' })).rejects.toThrow(ConflictException); }); }); @@ -3813,12 +3764,7 @@ export class OrdersService { for (const item of items) { await manager.save(OrderItem, { orderId: order.id, ...item }); - await manager.decrement( - Inventory, - { productId: item.productId }, - 'stock', - item.quantity, - ); + await manager.decrement(Inventory, { productId: item.productId }, 'stock', item.quantity); } // If this throws, everything rolls back @@ -3841,12 +3787,7 @@ export class TransferService { try { // Debit source account - await queryRunner.manager.decrement( - Account, - { id: fromId }, - 'balance', - amount, - ); + await queryRunner.manager.decrement(Account, { id: fromId }, 'balance', amount); // Verify sufficient funds const source = await queryRunner.manager.findOne(Account, { @@ -3857,12 +3798,7 @@ export class TransferService { } // Credit destination account - await queryRunner.manager.increment( - Account, - { id: toId }, - 'balance', - amount, - ); + await queryRunner.manager.increment(Account, { id: toId }, 'balance', amount); // Log the transaction await queryRunner.manager.save(TransactionLog, { @@ -3887,13 +3823,10 @@ export class TransferService { export class UsersRepository { constructor( @InjectRepository(User) private repo: Repository, - private dataSource: DataSource, + private dataSource: DataSource ) {} - async createWithProfile( - userData: CreateUserDto, - profileData: CreateProfileDto, - ): Promise { + async createWithProfile(userData: CreateUserDto, profileData: CreateProfileDto): Promise { return this.dataSource.transaction(async (manager) => { const user = await manager.save(User, userData); await manager.save(Profile, { ...profileData, userId: user.id }); @@ -4034,7 +3967,7 @@ export class UsersController { @SerializeOptions({ type: UserResponseDto }) async findAll(): Promise { const users = await this.usersService.findAll(); - return users.map(u => plainToInstance(UserResponseDto, u)); + return users.map((u) => plainToInstance(UserResponseDto, u)); } @Get(':id') @@ -4650,10 +4583,7 @@ export class UsersService { @Controller('users') export class UsersController { @Get(':id') - async findOne( - @Param('id') id: string, - @Headers('X-API-Version') version: string = '1', - ): Promise { + async findOne(@Param('id') id: string, @Headers('X-API-Version') version: string = '1'): Promise { return this.usersService.findOne(id, version); } } @@ -5137,11 +5067,7 @@ import { BullModule } from '@nestjs/bullmq'; }, }, }), - BullModule.registerQueue( - { name: 'email' }, - { name: 'reports' }, - { name: 'notifications' }, - ), + BullModule.registerQueue({ name: 'email' }, { name: 'reports' }, { name: 'notifications' }), ], }) export class QueueModule {} @@ -5149,9 +5075,7 @@ export class QueueModule {} // Producer: Add jobs to queue @Injectable() export class ReportsService { - constructor( - @InjectQueue('reports') private reportsQueue: Queue, - ) {} + constructor(@InjectQueue('reports') private reportsQueue: Queue) {} async requestReport(dto: GenerateReportDto): Promise<{ jobId: string }> { // Return immediately, process in background @@ -5249,7 +5173,7 @@ export class NotificationService { { attempts: 5, backoff: { type: 'exponential', delay: 5000 }, - }, + } ); } } @@ -5267,7 +5191,7 @@ export class ScheduledJobsService implements OnModuleInit { { repeat: { cron: '0 0 * * *' }, jobId: 'daily-cleanup', // Prevent duplicates - }, + } ); // Send digest every hour @@ -5277,7 +5201,7 @@ export class ScheduledJobsService implements OnModuleInit { { repeat: { every: 60 * 60 * 1000 }, jobId: 'hourly-digest', - }, + } ); } } @@ -5406,9 +5330,7 @@ export class DatabaseService implements OnApplicationShutdown { console.log(`Database service shutting down on ${signal}`); // Close all connections gracefully - await Promise.all( - this.connections.map((conn) => conn.close()), - ); + await Promise.all(this.connections.map((conn) => conn.close())); console.log('All database connections closed'); } @@ -5477,9 +5399,7 @@ export class HealthController { throw new ServiceUnavailableException('Shutting down'); } - return this.health.check([ - () => this.db.pingCheck('database'), - ]); + return this.health.check([() => this.db.pingCheck('database')]); } } @@ -5535,10 +5455,7 @@ export class RequestTracker implements NestMiddleware, OnApplicationShutdown { }); // Wait with timeout - await Promise.race([ - this.shutdownPromise, - new Promise((resolve) => setTimeout(resolve, 30000)), - ]); + await Promise.race([this.shutdownPromise, new Promise((resolve) => setTimeout(resolve, 30000))]); } console.log('All requests completed'); @@ -5608,9 +5525,7 @@ export const appConfig = registerAs('app', () => ({ // config/validation.schema.ts export const validationSchema = Joi.object({ - NODE_ENV: Joi.string() - .valid('development', 'production', 'test') - .default('development'), + NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'), PORT: Joi.number().default(3000), DB_HOST: Joi.string().required(), DB_PORT: Joi.number().default(5432), @@ -5684,7 +5599,7 @@ export class AppService { export class DatabaseService { constructor( @Inject(databaseConfig.KEY) - private dbConfig: ConfigType, + private dbConfig: ConfigType ) { // Full type inference! const host = this.dbConfig.host; // string @@ -5694,12 +5609,7 @@ export class DatabaseService { // Environment files support ConfigModule.forRoot({ - envFilePath: [ - `.env.${process.env.NODE_ENV}.local`, - `.env.${process.env.NODE_ENV}`, - '.env.local', - '.env', - ], + envFilePath: [`.env.${process.env.NODE_ENV}.local`, `.env.${process.env.NODE_ENV}`, '.env.local', '.env'], }); // .env.development @@ -5757,9 +5667,7 @@ logger.log('User ' + userId + ' created at ' + new Date()); async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: - process.env.NODE_ENV === 'production' - ? ['error', 'warn', 'log'] - : ['error', 'warn', 'log', 'debug', 'verbose'], + process.env.NODE_ENV === 'production' ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'], }); } @@ -5794,7 +5702,7 @@ export class JsonLogger implements LoggerService { timestamp: new Date().toISOString(), message, ...context, - }), + }) ); } @@ -5806,7 +5714,7 @@ export class JsonLogger implements LoggerService { message, trace, ...context, - }), + }) ); } @@ -5817,7 +5725,7 @@ export class JsonLogger implements LoggerService { timestamp: new Date().toISOString(), message, ...context, - }), + }) ); } @@ -5828,7 +5736,7 @@ export class JsonLogger implements LoggerService { timestamp: new Date().toISOString(), message, ...context, - }), + }) ); } } @@ -5878,7 +5786,7 @@ export class ContextLogger { userId: this.cls.get('userId'), message, ...data, - }), + }) ); } @@ -5893,7 +5801,7 @@ export class ContextLogger { error: error.message, stack: error.stack, ...data, - }), + }) ); } } @@ -5906,10 +5814,7 @@ import { LoggerModule } from 'nestjs-pino'; LoggerModule.forRoot({ pinoHttp: { level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', - transport: - process.env.NODE_ENV !== 'production' - ? { target: 'pino-pretty' } - : undefined, + transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined, redact: ['req.headers.authorization', 'req.body.password'], serializers: { req: (req) => ({ @@ -5955,4 +5860,4 @@ Reference: [NestJS Logger](https://docs.nestjs.com/techniques/logger) --- -*Generated by build-agents.ts on 2026-01-16* +_Generated by build-agents.ts on 2026-01-16_ diff --git a/.agents/skills/nestjs-best-practices/README.md b/.agents/skills/nestjs-best-practices/README.md index 10d1327..18b0f00 100644 --- a/.agents/skills/nestjs-best-practices/README.md +++ b/.agents/skills/nestjs-best-practices/README.md @@ -36,11 +36,12 @@ npx skills add Kadajett/agent-nestjs-skills -a claude-code -a cursor - `area-description.md` - Individual rule files - `scripts/` - Build scripts and utilities - `metadata.json` - Document metadata (version, organization, abstract) -- __`AGENTS.md`__ - Compiled output (generated) +- **`AGENTS.md`** - Compiled output (generated) ## Getting Started 1. Install dependencies: + ```bash cd scripts && npm install ``` @@ -74,7 +75,7 @@ npx skills add Kadajett/agent-nestjs-skills -a claude-code -a cursor Each rule file should follow this structure: -```markdown +````markdown --- title: Rule Title Here impact: MEDIUM @@ -91,6 +92,7 @@ Brief explanation of the rule and why it matters. ```typescript // Bad code example ``` +```` **Correct (description of what's right):** @@ -102,7 +104,6 @@ Optional explanatory text after examples. Reference: [NestJS Documentation](https://docs.nestjs.com) - ## File Naming Convention - Files starting with `_` are special (excluded from build) @@ -113,13 +114,13 @@ Reference: [NestJS Documentation](https://docs.nestjs.com) ## Impact Levels -| Level | Description | -|-------|-------------| -| CRITICAL | Violations cause runtime errors, security vulnerabilities, or architectural breakdown | -| HIGH | Significant impact on reliability, security, or maintainability | -| MEDIUM-HIGH | Notable impact on quality and developer experience | -| MEDIUM | Moderate impact on code quality and best practices | -| LOW-MEDIUM | Minor improvements for consistency and maintainability | +| Level | Description | +| ----------- | ------------------------------------------------------------------------------------- | +| CRITICAL | Violations cause runtime errors, security vulnerabilities, or architectural breakdown | +| HIGH | Significant impact on reliability, security, or maintainability | +| MEDIUM-HIGH | Notable impact on quality and developer experience | +| MEDIUM | Moderate impact on code quality and best practices | +| LOW-MEDIUM | Minor improvements for consistency and maintainability | ## Scripts @@ -160,4 +161,3 @@ These NestJS skills work with: - [Claude Code](https://claude.ai/code) - Anthropic's official CLI - [AdaL](https://sylph.ai/adal) - Self-evolving AI coding agent with MCP support - diff --git a/.agents/skills/nestjs-best-practices/SKILL.md b/.agents/skills/nestjs-best-practices/SKILL.md index de75eac..8b89b9f 100644 --- a/.agents/skills/nestjs-best-practices/SKILL.md +++ b/.agents/skills/nestjs-best-practices/SKILL.md @@ -4,7 +4,7 @@ description: NestJS best practices and architecture patterns for building produc license: MIT metadata: author: Kadajett - version: "1.1.0" + version: '1.1.0' --- # NestJS Best Practices @@ -24,18 +24,18 @@ Reference these guidelines when: ## Rule Categories by Priority -| Priority | Category | Impact | Prefix | -|----------|----------|--------|--------| -| 1 | Architecture | CRITICAL | `arch-` | -| 2 | Dependency Injection | CRITICAL | `di-` | -| 3 | Error Handling | HIGH | `error-` | -| 4 | Security | HIGH | `security-` | -| 5 | Performance | HIGH | `perf-` | -| 6 | Testing | MEDIUM-HIGH | `test-` | -| 7 | Database & ORM | MEDIUM-HIGH | `db-` | -| 8 | API Design | MEDIUM | `api-` | -| 9 | Microservices | MEDIUM | `micro-` | -| 10 | DevOps & Deployment | LOW-MEDIUM | `devops-` | +| Priority | Category | Impact | Prefix | +| -------- | -------------------- | ----------- | ----------- | +| 1 | Architecture | CRITICAL | `arch-` | +| 2 | Dependency Injection | CRITICAL | `di-` | +| 3 | Error Handling | HIGH | `error-` | +| 4 | Security | HIGH | `security-` | +| 5 | Performance | HIGH | `perf-` | +| 6 | Testing | MEDIUM-HIGH | `test-` | +| 7 | Database & ORM | MEDIUM-HIGH | `db-` | +| 8 | API Design | MEDIUM | `api-` | +| 9 | Microservices | MEDIUM | `micro-` | +| 10 | DevOps & Deployment | LOW-MEDIUM | `devops-` | ## Quick Reference @@ -120,6 +120,7 @@ rules/_sections.md ``` Each rule file contains: + - Brief explanation of why it matters - Incorrect code example with explanation - Correct code example with explanation diff --git a/.agents/skills/nestjs-best-practices/rules/api-use-dto-serialization.md b/.agents/skills/nestjs-best-practices/rules/api-use-dto-serialization.md index be95c07..525c805 100644 --- a/.agents/skills/nestjs-best-practices/rules/api-use-dto-serialization.md +++ b/.agents/skills/nestjs-best-practices/rules/api-use-dto-serialization.md @@ -126,7 +126,7 @@ export class UsersController { @SerializeOptions({ type: UserResponseDto }) async findAll(): Promise { const users = await this.usersService.findAll(); - return users.map(u => plainToInstance(UserResponseDto, u)); + return users.map((u) => plainToInstance(UserResponseDto, u)); } @Get(':id') diff --git a/.agents/skills/nestjs-best-practices/rules/api-versioning.md b/.agents/skills/nestjs-best-practices/rules/api-versioning.md index 5e4546c..98c100f 100644 --- a/.agents/skills/nestjs-best-practices/rules/api-versioning.md +++ b/.agents/skills/nestjs-best-practices/rules/api-versioning.md @@ -159,10 +159,7 @@ export class UsersService { @Controller('users') export class UsersController { @Get(':id') - async findOne( - @Param('id') id: string, - @Headers('X-API-Version') version: string = '1', - ): Promise { + async findOne(@Param('id') id: string, @Headers('X-API-Version') version: string = '1'): Promise { return this.usersService.findOne(id, version); } } diff --git a/.agents/skills/nestjs-best-practices/rules/arch-avoid-circular-deps.md b/.agents/skills/nestjs-best-practices/rules/arch-avoid-circular-deps.md index aea5c8b..7199720 100644 --- a/.agents/skills/nestjs-best-practices/rules/arch-avoid-circular-deps.md +++ b/.agents/skills/nestjs-best-practices/rules/arch-avoid-circular-deps.md @@ -1,7 +1,7 @@ --- title: Avoid Circular Dependencies impact: CRITICAL -impactDescription: "#1 cause of runtime crashes" +impactDescription: '#1 cause of runtime crashes' tags: architecture, modules, dependencies --- diff --git a/.agents/skills/nestjs-best-practices/rules/arch-feature-modules.md b/.agents/skills/nestjs-best-practices/rules/arch-feature-modules.md index 4f3ef56..9cbe5df 100644 --- a/.agents/skills/nestjs-best-practices/rules/arch-feature-modules.md +++ b/.agents/skills/nestjs-best-practices/rules/arch-feature-modules.md @@ -1,7 +1,7 @@ --- title: Organize by Feature Modules impact: CRITICAL -impactDescription: "3-5x faster onboarding and development" +impactDescription: '3-5x faster onboarding and development' tags: architecture, modules, organization --- diff --git a/.agents/skills/nestjs-best-practices/rules/arch-single-responsibility.md b/.agents/skills/nestjs-best-practices/rules/arch-single-responsibility.md index 6ffc46e..052a066 100644 --- a/.agents/skills/nestjs-best-practices/rules/arch-single-responsibility.md +++ b/.agents/skills/nestjs-best-practices/rules/arch-single-responsibility.md @@ -1,7 +1,7 @@ --- title: Single Responsibility for Services impact: CRITICAL -impactDescription: "40%+ improvement in testability" +impactDescription: '40%+ improvement in testability' tags: architecture, services, single-responsibility --- @@ -19,7 +19,7 @@ export class UserAndOrderService { private userRepo: UserRepository, private orderRepo: OrderRepository, private mailer: MailService, - private payment: PaymentService, + private payment: PaymentService ) {} async createUser(dto: CreateUserDto) { @@ -90,7 +90,7 @@ export class OrdersController { constructor( private orders: OrdersService, private payment: PaymentService, - private notifications: NotificationService, + private notifications: NotificationService ) {} @Post() diff --git a/.agents/skills/nestjs-best-practices/rules/arch-use-events.md b/.agents/skills/nestjs-best-practices/rules/arch-use-events.md index f8cda27..ed08cf1 100644 --- a/.agents/skills/nestjs-best-practices/rules/arch-use-events.md +++ b/.agents/skills/nestjs-best-practices/rules/arch-use-events.md @@ -20,7 +20,7 @@ export class OrdersService { private emailService: EmailService, private analyticsService: AnalyticsService, private notificationService: NotificationService, - private loyaltyService: LoyaltyService, + private loyaltyService: LoyaltyService ) {} async createOrder(dto: CreateOrderDto): Promise { @@ -51,7 +51,7 @@ export class OrderCreatedEvent { public readonly orderId: string, public readonly userId: string, public readonly items: OrderItem[], - public readonly total: number, + public readonly total: number ) {} } @@ -60,17 +60,14 @@ export class OrderCreatedEvent { export class OrdersService { constructor( private eventEmitter: EventEmitter2, - private repo: Repository, + private repo: Repository ) {} async createOrder(dto: CreateOrderDto): Promise { const order = await this.repo.save(dto); // Emit event - no knowledge of consumers - this.eventEmitter.emit( - 'order.created', - new OrderCreatedEvent(order.id, order.userId, order.items, order.total), - ); + this.eventEmitter.emit('order.created', new OrderCreatedEvent(order.id, order.userId, order.items, order.total)); return order; } diff --git a/.agents/skills/nestjs-best-practices/rules/arch-use-repository-pattern.md b/.agents/skills/nestjs-best-practices/rules/arch-use-repository-pattern.md index 75df381..686dc4d 100644 --- a/.agents/skills/nestjs-best-practices/rules/arch-use-repository-pattern.md +++ b/.agents/skills/nestjs-best-practices/rules/arch-use-repository-pattern.md @@ -15,9 +15,7 @@ Create custom repositories to encapsulate complex queries and database logic. Th // Complex queries in services @Injectable() export class UsersService { - constructor( - @InjectRepository(User) private repo: Repository, - ) {} + constructor(@InjectRepository(User) private repo: Repository) {} async findActiveWithOrders(minOrders: number): Promise { // Complex query logic mixed with business logic @@ -42,9 +40,7 @@ export class UsersService { // Custom repository with encapsulated queries @Injectable() export class UsersRepository { - constructor( - @InjectRepository(User) private repo: Repository, - ) {} + constructor(@InjectRepository(User) private repo: Repository) {} async findById(id: string): Promise { return this.repo.findOne({ where: { id } }); diff --git a/.agents/skills/nestjs-best-practices/rules/db-use-transactions.md b/.agents/skills/nestjs-best-practices/rules/db-use-transactions.md index 543bf97..9a5ac3a 100644 --- a/.agents/skills/nestjs-best-practices/rules/db-use-transactions.md +++ b/.agents/skills/nestjs-best-practices/rules/db-use-transactions.md @@ -47,12 +47,7 @@ export class OrdersService { for (const item of items) { await manager.save(OrderItem, { orderId: order.id, ...item }); - await manager.decrement( - Inventory, - { productId: item.productId }, - 'stock', - item.quantity, - ); + await manager.decrement(Inventory, { productId: item.productId }, 'stock', item.quantity); } // If this throws, everything rolls back @@ -75,12 +70,7 @@ export class TransferService { try { // Debit source account - await queryRunner.manager.decrement( - Account, - { id: fromId }, - 'balance', - amount, - ); + await queryRunner.manager.decrement(Account, { id: fromId }, 'balance', amount); // Verify sufficient funds const source = await queryRunner.manager.findOne(Account, { @@ -91,12 +81,7 @@ export class TransferService { } // Credit destination account - await queryRunner.manager.increment( - Account, - { id: toId }, - 'balance', - amount, - ); + await queryRunner.manager.increment(Account, { id: toId }, 'balance', amount); // Log the transaction await queryRunner.manager.save(TransactionLog, { @@ -121,13 +106,10 @@ export class TransferService { export class UsersRepository { constructor( @InjectRepository(User) private repo: Repository, - private dataSource: DataSource, + private dataSource: DataSource ) {} - async createWithProfile( - userData: CreateUserDto, - profileData: CreateProfileDto, - ): Promise { + async createWithProfile(userData: CreateUserDto, profileData: CreateProfileDto): Promise { return this.dataSource.transaction(async (manager) => { const user = await manager.save(User, userData); await manager.save(Profile, { ...profileData, userId: user.id }); diff --git a/.agents/skills/nestjs-best-practices/rules/devops-graceful-shutdown.md b/.agents/skills/nestjs-best-practices/rules/devops-graceful-shutdown.md index b3b18f8..f12d8e4 100644 --- a/.agents/skills/nestjs-best-practices/rules/devops-graceful-shutdown.md +++ b/.agents/skills/nestjs-best-practices/rules/devops-graceful-shutdown.md @@ -79,9 +79,7 @@ export class DatabaseService implements OnApplicationShutdown { console.log(`Database service shutting down on ${signal}`); // Close all connections gracefully - await Promise.all( - this.connections.map((conn) => conn.close()), - ); + await Promise.all(this.connections.map((conn) => conn.close())); console.log('All database connections closed'); } @@ -150,9 +148,7 @@ export class HealthController { throw new ServiceUnavailableException('Shutting down'); } - return this.health.check([ - () => this.db.pingCheck('database'), - ]); + return this.health.check([() => this.db.pingCheck('database')]); } } @@ -208,10 +204,7 @@ export class RequestTracker implements NestMiddleware, OnApplicationShutdown { }); // Wait with timeout - await Promise.race([ - this.shutdownPromise, - new Promise((resolve) => setTimeout(resolve, 30000)), - ]); + await Promise.race([this.shutdownPromise, new Promise((resolve) => setTimeout(resolve, 30000))]); } console.log('All requests completed'); diff --git a/.agents/skills/nestjs-best-practices/rules/devops-use-config-module.md b/.agents/skills/nestjs-best-practices/rules/devops-use-config-module.md index a9483de..7648c15 100644 --- a/.agents/skills/nestjs-best-practices/rules/devops-use-config-module.md +++ b/.agents/skills/nestjs-best-practices/rules/devops-use-config-module.md @@ -61,9 +61,7 @@ export const appConfig = registerAs('app', () => ({ // config/validation.schema.ts export const validationSchema = Joi.object({ - NODE_ENV: Joi.string() - .valid('development', 'production', 'test') - .default('development'), + NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'), PORT: Joi.number().default(3000), DB_HOST: Joi.string().required(), DB_PORT: Joi.number().default(5432), @@ -137,7 +135,7 @@ export class AppService { export class DatabaseService { constructor( @Inject(databaseConfig.KEY) - private dbConfig: ConfigType, + private dbConfig: ConfigType ) { // Full type inference! const host = this.dbConfig.host; // string @@ -147,12 +145,7 @@ export class DatabaseService { // Environment files support ConfigModule.forRoot({ - envFilePath: [ - `.env.${process.env.NODE_ENV}.local`, - `.env.${process.env.NODE_ENV}`, - '.env.local', - '.env', - ], + envFilePath: [`.env.${process.env.NODE_ENV}.local`, `.env.${process.env.NODE_ENV}`, '.env.local', '.env'], }); // .env.development diff --git a/.agents/skills/nestjs-best-practices/rules/devops-use-logging.md b/.agents/skills/nestjs-best-practices/rules/devops-use-logging.md index 5fb0162..ca1405b 100644 --- a/.agents/skills/nestjs-best-practices/rules/devops-use-logging.md +++ b/.agents/skills/nestjs-best-practices/rules/devops-use-logging.md @@ -45,9 +45,7 @@ logger.log('User ' + userId + ' created at ' + new Date()); async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: - process.env.NODE_ENV === 'production' - ? ['error', 'warn', 'log'] - : ['error', 'warn', 'log', 'debug', 'verbose'], + process.env.NODE_ENV === 'production' ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'], }); } @@ -82,7 +80,7 @@ export class JsonLogger implements LoggerService { timestamp: new Date().toISOString(), message, ...context, - }), + }) ); } @@ -94,7 +92,7 @@ export class JsonLogger implements LoggerService { message, trace, ...context, - }), + }) ); } @@ -105,7 +103,7 @@ export class JsonLogger implements LoggerService { timestamp: new Date().toISOString(), message, ...context, - }), + }) ); } @@ -116,7 +114,7 @@ export class JsonLogger implements LoggerService { timestamp: new Date().toISOString(), message, ...context, - }), + }) ); } } @@ -166,7 +164,7 @@ export class ContextLogger { userId: this.cls.get('userId'), message, ...data, - }), + }) ); } @@ -181,7 +179,7 @@ export class ContextLogger { error: error.message, stack: error.stack, ...data, - }), + }) ); } } @@ -194,10 +192,7 @@ import { LoggerModule } from 'nestjs-pino'; LoggerModule.forRoot({ pinoHttp: { level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', - transport: - process.env.NODE_ENV !== 'production' - ? { target: 'pino-pretty' } - : undefined, + transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined, redact: ['req.headers.authorization', 'req.body.password'], serializers: { req: (req) => ({ diff --git a/.agents/skills/nestjs-best-practices/rules/di-avoid-service-locator.md b/.agents/skills/nestjs-best-practices/rules/di-avoid-service-locator.md index d4c04b4..ee17be2 100644 --- a/.agents/skills/nestjs-best-practices/rules/di-avoid-service-locator.md +++ b/.agents/skills/nestjs-best-practices/rules/di-avoid-service-locator.md @@ -55,7 +55,7 @@ export class OrdersService { constructor( private usersService: UsersService, private inventoryService: InventoryService, - private paymentService: PaymentService, + private paymentService: PaymentService ) {} async createOrder(dto: CreateOrderDto): Promise { diff --git a/.agents/skills/nestjs-best-practices/rules/di-interface-segregation.md b/.agents/skills/nestjs-best-practices/rules/di-interface-segregation.md index 8c96cd8..845f69f 100644 --- a/.agents/skills/nestjs-best-practices/rules/di-interface-segregation.md +++ b/.agents/skills/nestjs-best-practices/rules/di-interface-segregation.md @@ -28,14 +28,14 @@ interface NotificationService { @Injectable() export class OrdersService { constructor( - private notifications: NotificationService, // Depends on 8 methods, uses 1 + private notifications: NotificationService // Depends on 8 methods, uses 1 ) {} async confirmOrder(order: Order): Promise { await this.notifications.sendEmail( order.customer.email, 'Order Confirmed', - `Your order ${order.id} has been confirmed.`, + `Your order ${order.id} has been confirmed.` ); } } @@ -43,12 +43,12 @@ export class OrdersService { // Testing is painful - must mock unused methods const mockNotificationService = { sendEmail: jest.fn(), - sendSms: jest.fn(), // Never used, but required - sendPush: jest.fn(), // Never used, but required - sendSlack: jest.fn(), // Never used, but required - logNotification: jest.fn(), // Never used, but required + sendSms: jest.fn(), // Never used, but required + sendPush: jest.fn(), // Never used, but required + sendSlack: jest.fn(), // Never used, but required + logNotification: jest.fn(), // Never used, but required getDeliveryStatus: jest.fn(), // Never used, but required - retryFailed: jest.fn(), // Never used, but required + retryFailed: jest.fn(), // Never used, but required scheduleNotification: jest.fn(), // Never used, but required }; ``` @@ -105,14 +105,14 @@ export class SendGridEmailService implements EmailSender { @Injectable() export class OrdersService { constructor( - @Inject(EMAIL_SENDER) private emailSender: EmailSender, // Minimal dependency + @Inject(EMAIL_SENDER) private emailSender: EmailSender // Minimal dependency ) {} async confirmOrder(order: Order): Promise { await this.emailSender.sendEmail( order.customer.email, 'Order Confirmed', - `Your order ${order.id} has been confirmed.`, + `Your order ${order.id} has been confirmed.` ); } } @@ -150,7 +150,7 @@ type MultiChannelSender = EmailSender & SmsSender & PushSender; export class AlertService { constructor( @Inject(MULTI_CHANNEL_SENDER) - private sender: EmailSender & SmsSender, + private sender: EmailSender & SmsSender ) {} async sendCriticalAlert(user: User, message: string): Promise { diff --git a/.agents/skills/nestjs-best-practices/rules/di-liskov-substitution.md b/.agents/skills/nestjs-best-practices/rules/di-liskov-substitution.md index d670117..a031fbe 100644 --- a/.agents/skills/nestjs-best-practices/rules/di-liskov-substitution.md +++ b/.agents/skills/nestjs-best-practices/rules/di-liskov-substitution.md @@ -178,9 +178,7 @@ export class OrdersService { ```typescript // Shared test suite that any implementation must pass -function testPaymentGatewayContract( - createGateway: () => PaymentGateway, -) { +function testPaymentGatewayContract(createGateway: () => PaymentGateway) { describe('PaymentGateway contract', () => { let gateway: PaymentGateway; @@ -197,13 +195,11 @@ function testPaymentGatewayContract( }); it('throws InvalidCurrencyException for unsupported currency', async () => { - await expect(gateway.charge(1000, 'INVALID')) - .rejects.toThrow(InvalidCurrencyException); + await expect(gateway.charge(1000, 'INVALID')).rejects.toThrow(InvalidCurrencyException); }); it('throws TransactionNotFoundException for invalid refund', async () => { - await expect(gateway.refund('nonexistent')) - .rejects.toThrow(TransactionNotFoundException); + await expect(gateway.refund('nonexistent')).rejects.toThrow(TransactionNotFoundException); }); }); } diff --git a/.agents/skills/nestjs-best-practices/rules/di-prefer-constructor-injection.md b/.agents/skills/nestjs-best-practices/rules/di-prefer-constructor-injection.md index c4a3274..9f4659b 100644 --- a/.agents/skills/nestjs-best-practices/rules/di-prefer-constructor-injection.md +++ b/.agents/skills/nestjs-best-practices/rules/di-prefer-constructor-injection.md @@ -40,7 +40,7 @@ export class UsersService { export class UsersService { constructor( private readonly userRepo: UserRepository, - @Inject('CONFIG') private readonly config: ConfigType, + @Inject('CONFIG') private readonly config: ConfigType ) {} async findAll(): Promise { diff --git a/.agents/skills/nestjs-best-practices/rules/di-use-interfaces-tokens.md b/.agents/skills/nestjs-best-practices/rules/di-use-interfaces-tokens.md index f5376a1..d0fd149 100644 --- a/.agents/skills/nestjs-best-practices/rules/di-use-interfaces-tokens.md +++ b/.agents/skills/nestjs-best-practices/rules/di-use-interfaces-tokens.md @@ -19,7 +19,9 @@ interface PaymentGateway { @Injectable() export class StripeService implements PaymentGateway { - charge(amount: number) { /* ... */ } + charge(amount: number) { + /* ... */ + } } @Injectable() @@ -58,9 +60,7 @@ export class MockPaymentService implements PaymentGateway { providers: [ { provide: PAYMENT_GATEWAY, - useClass: process.env.NODE_ENV === 'test' - ? MockPaymentService - : StripeService, + useClass: process.env.NODE_ENV === 'test' ? MockPaymentService : StripeService, }, ], exports: [PAYMENT_GATEWAY], @@ -70,9 +70,7 @@ export class PaymentModule {} // Injection @Injectable() export class OrdersService { - constructor( - @Inject(PAYMENT_GATEWAY) private payment: PaymentGateway, - ) {} + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} async createOrder(dto: CreateOrderDto) { await this.payment.charge(dto.amount); diff --git a/.agents/skills/nestjs-best-practices/rules/error-throw-http-exceptions.md b/.agents/skills/nestjs-best-practices/rules/error-throw-http-exceptions.md index 6aad9fa..70613f2 100644 --- a/.agents/skills/nestjs-best-practices/rules/error-throw-http-exceptions.md +++ b/.agents/skills/nestjs-best-practices/rules/error-throw-http-exceptions.md @@ -88,7 +88,7 @@ export class UsersController { export class EntityNotFoundException extends Error { constructor( public readonly entity: string, - public readonly id: string, + public readonly id: string ) { super(`${entity} with ID "${id}" not found`); } diff --git a/.agents/skills/nestjs-best-practices/rules/error-use-exception-filters.md b/.agents/skills/nestjs-best-practices/rules/error-use-exception-filters.md index 635823a..ae9d779 100644 --- a/.agents/skills/nestjs-best-practices/rules/error-use-exception-filters.md +++ b/.agents/skills/nestjs-best-practices/rules/error-use-exception-filters.md @@ -95,20 +95,11 @@ export class AllExceptionsFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); - const status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; - const message = - exception instanceof HttpException - ? exception.message - : 'Internal server error'; + const message = exception instanceof HttpException ? exception.message : 'Internal server error'; - this.logger.error( - `${request.method} ${request.url}`, - exception instanceof Error ? exception.stack : exception, - ); + this.logger.error(`${request.method} ${request.url}`, exception instanceof Error ? exception.stack : exception); response.status(status).json({ statusCode: status, @@ -120,10 +111,7 @@ export class AllExceptionsFilter implements ExceptionFilter { } // Register globally in main.ts -app.useGlobalFilters( - new AllExceptionsFilter(app.get(Logger)), - new DomainExceptionFilter(), -); +app.useGlobalFilters(new AllExceptionsFilter(app.get(Logger)), new DomainExceptionFilter()); // Or via module @Module({ diff --git a/.agents/skills/nestjs-best-practices/rules/micro-use-queues.md b/.agents/skills/nestjs-best-practices/rules/micro-use-queues.md index f9bc672..eaa5328 100644 --- a/.agents/skills/nestjs-best-practices/rules/micro-use-queues.md +++ b/.agents/skills/nestjs-best-practices/rules/micro-use-queues.md @@ -64,11 +64,7 @@ import { BullModule } from '@nestjs/bullmq'; }, }, }), - BullModule.registerQueue( - { name: 'email' }, - { name: 'reports' }, - { name: 'notifications' }, - ), + BullModule.registerQueue({ name: 'email' }, { name: 'reports' }, { name: 'notifications' }), ], }) export class QueueModule {} @@ -76,9 +72,7 @@ export class QueueModule {} // Producer: Add jobs to queue @Injectable() export class ReportsService { - constructor( - @InjectQueue('reports') private reportsQueue: Queue, - ) {} + constructor(@InjectQueue('reports') private reportsQueue: Queue) {} async requestReport(dto: GenerateReportDto): Promise<{ jobId: string }> { // Return immediately, process in background @@ -176,7 +170,7 @@ export class NotificationService { { attempts: 5, backoff: { type: 'exponential', delay: 5000 }, - }, + } ); } } @@ -194,7 +188,7 @@ export class ScheduledJobsService implements OnModuleInit { { repeat: { cron: '0 0 * * *' }, jobId: 'daily-cleanup', // Prevent duplicates - }, + } ); // Send digest every hour @@ -204,7 +198,7 @@ export class ScheduledJobsService implements OnModuleInit { { repeat: { every: 60 * 60 * 1000 }, jobId: 'hourly-digest', - }, + } ); } } diff --git a/.agents/skills/nestjs-best-practices/rules/perf-async-hooks.md b/.agents/skills/nestjs-best-practices/rules/perf-async-hooks.md index 7ca0077..346a39f 100644 --- a/.agents/skills/nestjs-best-practices/rules/perf-async-hooks.md +++ b/.agents/skills/nestjs-best-practices/rules/perf-async-hooks.md @@ -64,7 +64,7 @@ export class DatabaseService implements OnModuleInit { export class CacheWarmerService implements OnApplicationBootstrap { constructor( private cache: CacheService, - private products: ProductsService, + private products: ProductsService ) {} async onApplicationBootstrap(): Promise { diff --git a/.agents/skills/nestjs-best-practices/rules/perf-lazy-loading.md b/.agents/skills/nestjs-best-practices/rules/perf-lazy-loading.md index 8bcc582..e0b5c61 100644 --- a/.agents/skills/nestjs-best-practices/rules/perf-lazy-loading.md +++ b/.agents/skills/nestjs-best-practices/rules/perf-lazy-loading.md @@ -81,10 +81,7 @@ export class ModuleLoaderService { constructor(private lazyModuleLoader: LazyModuleLoader) {} - async load( - key: string, - importFn: () => Promise<{ default: Type } | Type>, - ): Promise { + async load(key: string, importFn: () => Promise<{ default: Type } | Type>): Promise { if (!this.loadedModules.has(key)) { const module = await importFn(); const moduleType = 'default' in module ? module.default : module; diff --git a/.agents/skills/nestjs-best-practices/rules/perf-use-caching.md b/.agents/skills/nestjs-best-practices/rules/perf-use-caching.md index 7170690..52fd3c8 100644 --- a/.agents/skills/nestjs-best-practices/rules/perf-use-caching.md +++ b/.agents/skills/nestjs-best-practices/rules/perf-use-caching.md @@ -51,9 +51,7 @@ export class UsersService { imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ - stores: [ - new KeyvRedis(config.get('REDIS_URL')), - ], + stores: [new KeyvRedis(config.get('REDIS_URL'))], ttl: 60 * 1000, // Default 60s }), }), @@ -66,7 +64,7 @@ export class AppModule {} export class ProductsService { constructor( @Inject(CACHE_MANAGER) private cache: Cache, - private productsRepo: ProductRepository, + private productsRepo: ProductRepository ) {} async getPopular(): Promise { @@ -117,10 +115,7 @@ export class CacheInvalidationService { @OnEvent('product.updated') @OnEvent('product.deleted') async invalidateProductCaches(event: ProductEvent) { - await Promise.all([ - this.cache.del('products:popular'), - this.cache.del(`product:${event.productId}`), - ]); + await Promise.all([this.cache.del('products:popular'), this.cache.del(`product:${event.productId}`)]); } } ``` diff --git a/.agents/skills/nestjs-best-practices/rules/security-auth-jwt.md b/.agents/skills/nestjs-best-practices/rules/security-auth-jwt.md index a0d1ff0..c5ca428 100644 --- a/.agents/skills/nestjs-best-practices/rules/security-auth-jwt.md +++ b/.agents/skills/nestjs-best-practices/rules/security-auth-jwt.md @@ -111,7 +111,7 @@ export class AuthService { export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private config: ConfigService, - private usersService: UsersService, + private usersService: UsersService ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), diff --git a/.agents/skills/nestjs-best-practices/rules/security-use-guards.md b/.agents/skills/nestjs-best-practices/rules/security-use-guards.md index fb1359c..b5d4e85 100644 --- a/.agents/skills/nestjs-best-practices/rules/security-use-guards.md +++ b/.agents/skills/nestjs-best-practices/rules/security-use-guards.md @@ -47,15 +47,12 @@ export class AdminController { export class JwtAuthGuard implements CanActivate { constructor( private jwtService: JwtService, - private reflector: Reflector, + private reflector: Reflector ) {} async canActivate(context: ExecutionContext): Promise { // Check for @Public() decorator - const isPublic = this.reflector.getAllAndOverride('isPublic', [ - context.getHandler(), - context.getClass(), - ]); + const isPublic = this.reflector.getAllAndOverride('isPublic', [context.getHandler(), context.getClass()]); if (isPublic) return true; const request = context.switchToHttp().getRequest(); @@ -85,10 +82,7 @@ export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride('roles', [ - context.getHandler(), - context.getClass(), - ]); + const requiredRoles = this.reflector.getAllAndOverride('roles', [context.getHandler(), context.getClass()]); if (!requiredRoles) return true; diff --git a/.agents/skills/nestjs-best-practices/rules/security-validate-all-input.md b/.agents/skills/nestjs-best-practices/rules/security-validate-all-input.md index f489d06..16be50d 100644 --- a/.agents/skills/nestjs-best-practices/rules/security-validate-all-input.md +++ b/.agents/skills/nestjs-best-practices/rules/security-validate-all-input.md @@ -30,9 +30,9 @@ export class UsersController { // DTOs without validation decorators export class CreateUserDto { - name: string; // No validation - email: string; // Could be "not-an-email" - age: number; // Could be "abc" or -999 + name: string; // No validation + email: string; // Could be "not-an-email" + age: number; // Could be "abc" or -999 } ``` @@ -45,13 +45,13 @@ async function bootstrap() { app.useGlobalPipes( new ValidationPipe({ - whitelist: true, // Strip unknown properties - forbidNonWhitelisted: true, // Throw on unknown properties - transform: true, // Auto-transform to DTO types + whitelist: true, // Strip unknown properties + forbidNonWhitelisted: true, // Throw on unknown properties + transform: true, // Auto-transform to DTO types transformOptions: { enableImplicitConversion: true, }, - }), + }) ); await app.listen(3000); diff --git a/.agents/skills/nestjs-best-practices/rules/test-e2e-supertest.md b/.agents/skills/nestjs-best-practices/rules/test-e2e-supertest.md index 4265513..59e422d 100644 --- a/.agents/skills/nestjs-best-practices/rules/test-e2e-supertest.md +++ b/.agents/skills/nestjs-best-practices/rules/test-e2e-supertest.md @@ -61,7 +61,7 @@ describe('UsersController (e2e)', () => { whitelist: true, transform: true, forbidNonWhitelisted: true, - }), + }) ); await app.init(); @@ -97,9 +97,7 @@ describe('UsersController (e2e)', () => { describe('/users/:id (GET)', () => { it('should return 404 for non-existent user', () => { - return request(app.getHttpServer()) - .get('/users/non-existent-id') - .expect(404); + return request(app.getHttpServer()).get('/users/non-existent-id').expect(404); }); }); }); @@ -127,9 +125,7 @@ describe('Protected Routes (e2e)', () => { }); it('should return 401 without token', () => { - return request(app.getHttpServer()) - .get('/users/me') - .expect(401); + return request(app.getHttpServer()).get('/users/me').expect(401); }); it('should return user profile with valid token', () => { diff --git a/.agents/skills/nestjs-best-practices/rules/test-mock-external-services.md b/.agents/skills/nestjs-best-practices/rules/test-mock-external-services.md index e29b595..77b3e59 100644 --- a/.agents/skills/nestjs-best-practices/rules/test-mock-external-services.md +++ b/.agents/skills/nestjs-best-practices/rules/test-mock-external-services.md @@ -84,9 +84,7 @@ describe('WeatherService', () => { }); it('should handle API timeout', async () => { - httpService.get.mockReturnValue( - throwError(() => new Error('ETIMEDOUT')), - ); + httpService.get.mockReturnValue(throwError(() => new Error('ETIMEDOUT'))); await expect(service.getWeather('NYC')).rejects.toThrow('Weather service unavailable'); }); @@ -95,7 +93,7 @@ describe('WeatherService', () => { httpService.get.mockReturnValue( throwError(() => ({ response: { status: 429, data: { message: 'Rate limited' } }, - })), + })) ); await expect(service.getWeather('NYC')).rejects.toThrow(TooManyRequestsException); @@ -117,10 +115,7 @@ describe('UsersService', () => { }; const module = await Test.createTestingModule({ - providers: [ - UsersService, - { provide: getRepositoryToken(User), useValue: mockRepo }, - ], + providers: [UsersService, { provide: getRepositoryToken(User), useValue: mockRepo }], }).compile(); service = module.get(UsersService); diff --git a/.agents/skills/nestjs-best-practices/rules/test-use-testing-module.md b/.agents/skills/nestjs-best-practices/rules/test-use-testing-module.md index de256f5..bda1ee8 100644 --- a/.agents/skills/nestjs-best-practices/rules/test-use-testing-module.md +++ b/.agents/skills/nestjs-best-practices/rules/test-use-testing-module.md @@ -86,9 +86,7 @@ describe('UsersService', () => { it('should throw on duplicate email', async () => { repo.findOne.mockResolvedValue({ id: '1', email: 'test@test.com' }); - await expect( - service.create({ name: 'Test', email: 'test@test.com' }), - ).rejects.toThrow(ConflictException); + await expect(service.create({ name: 'Test', email: 'test@test.com' })).rejects.toThrow(ConflictException); }); }); diff --git a/.agents/skills/nestjs-best-practices/scripts/build-agents.ts b/.agents/skills/nestjs-best-practices/scripts/build-agents.ts index a0b9039..2c64b11 100644 --- a/.agents/skills/nestjs-best-practices/scripts/build-agents.ts +++ b/.agents/skills/nestjs-best-practices/scripts/build-agents.ts @@ -98,7 +98,7 @@ function parseFrontmatter(content: string): { frontmatter: RuleFrontmatter | nul return { frontmatter: frontmatter as RuleFrontmatter, - body: body.trim() + body: body.trim(), }; } @@ -118,8 +118,7 @@ function readMetadata(): any { function readRules(): Rule[] { const rulesDir = path.join(__dirname, '..', 'rules'); - const files = fs.readdirSync(rulesDir) - .filter(f => f.endsWith('.md') && !f.startsWith('_')); + const files = fs.readdirSync(rulesDir).filter((f) => f.endsWith('.md') && !f.startsWith('_')); const rules: Rule[] = []; @@ -144,7 +143,7 @@ function readRules(): Rule[] { frontmatter, content: body, category: category.name, - categorySection: category.section + categorySection: category.section, }); } diff --git a/.agents/skills/next-best-practices/SKILL.md b/.agents/skills/next-best-practices/SKILL.md index 437896b..3d5e686 100644 --- a/.agents/skills/next-best-practices/SKILL.md +++ b/.agents/skills/next-best-practices/SKILL.md @@ -11,6 +11,7 @@ Apply these rules when writing or reviewing Next.js code. ## File Conventions See [file-conventions.md](./file-conventions.md) for: + - Project structure and special files - Route segments (dynamic, catch-all, groups) - Parallel and intercepting routes @@ -21,6 +22,7 @@ See [file-conventions.md](./file-conventions.md) for: Detect invalid React Server Component patterns. See [rsc-boundaries.md](./rsc-boundaries.md) for: + - Async client component detection (invalid) - Non-serializable props detection - Server Action exceptions @@ -30,6 +32,7 @@ See [rsc-boundaries.md](./rsc-boundaries.md) for: Next.js 15+ async API changes. See [async-patterns.md](./async-patterns.md) for: + - Async `params` and `searchParams` - Async `cookies()` and `headers()` - Migration codemod @@ -37,18 +40,21 @@ See [async-patterns.md](./async-patterns.md) for: ## Runtime Selection See [runtime-selection.md](./runtime-selection.md) for: + - Default to Node.js runtime - When Edge runtime is appropriate ## Directives See [directives.md](./directives.md) for: + - `'use client'`, `'use server'` (React) - `'use cache'` (Next.js) ## Functions See [functions.md](./functions.md) for: + - Navigation hooks: `useRouter`, `usePathname`, `useSearchParams`, `useParams` - Server functions: `cookies`, `headers`, `draftMode`, `after` - Generate functions: `generateStaticParams`, `generateMetadata` @@ -56,6 +62,7 @@ See [functions.md](./functions.md) for: ## Error Handling See [error-handling.md](./error-handling.md) for: + - `error.tsx`, `global-error.tsx`, `not-found.tsx` - `redirect`, `permanentRedirect`, `notFound` - `forbidden`, `unauthorized` (auth errors) @@ -64,6 +71,7 @@ See [error-handling.md](./error-handling.md) for: ## Data Patterns See [data-patterns.md](./data-patterns.md) for: + - Server Components vs Server Actions vs Route Handlers - Avoiding data waterfalls (`Promise.all`, Suspense, preload) - Client component data fetching @@ -71,6 +79,7 @@ See [data-patterns.md](./data-patterns.md) for: ## Route Handlers See [route-handlers.md](./route-handlers.md) for: + - `route.ts` basics - GET handler conflicts with `page.tsx` - Environment behavior (no React DOM) @@ -79,6 +88,7 @@ See [route-handlers.md](./route-handlers.md) for: ## Metadata & OG Images See [metadata.md](./metadata.md) for: + - Static and dynamic metadata - `generateMetadata` function - OG image generation with `next/og` @@ -87,6 +97,7 @@ See [metadata.md](./metadata.md) for: ## Image Optimization See [image.md](./image.md) for: + - Always use `next/image` over `` - Remote images configuration - Responsive `sizes` attribute @@ -96,6 +107,7 @@ See [image.md](./image.md) for: ## Font Optimization See [font.md](./font.md) for: + - `next/font` setup - Google Fonts, local fonts - Tailwind CSS integration @@ -104,6 +116,7 @@ See [font.md](./font.md) for: ## Bundling See [bundling.md](./bundling.md) for: + - Server-incompatible packages - CSS imports (not link tags) - Polyfills (already included) @@ -113,6 +126,7 @@ See [bundling.md](./bundling.md) for: ## Scripts See [scripts.md](./scripts.md) for: + - `next/script` vs native script tags - Inline scripts need `id` - Loading strategies @@ -121,6 +135,7 @@ See [scripts.md](./scripts.md) for: ## Hydration Errors See [hydration-error.md](./hydration-error.md) for: + - Common causes (browser APIs, dates, invalid HTML) - Debugging with error overlay - Fixes for each cause @@ -128,12 +143,14 @@ See [hydration-error.md](./hydration-error.md) for: ## Suspense Boundaries See [suspense-boundaries.md](./suspense-boundaries.md) for: + - CSR bailout with `useSearchParams` and `usePathname` - Which hooks require Suspense boundaries ## Parallel & Intercepting Routes See [parallel-routes.md](./parallel-routes.md) for: + - Modal patterns with `@slot` and `(.)` interceptors - `default.tsx` for fallbacks - Closing modals correctly with `router.back()` @@ -141,6 +158,7 @@ See [parallel-routes.md](./parallel-routes.md) for: ## Self-Hosting See [self-hosting.md](./self-hosting.md) for: + - `output: 'standalone'` for Docker - Cache handlers for multi-instance ISR - What works vs needs extra setup @@ -148,6 +166,6 @@ See [self-hosting.md](./self-hosting.md) for: ## Debug Tricks See [debug-tricks.md](./debug-tricks.md) for: + - MCP endpoint for AI-assisted debugging - Rebuild specific routes with `--debug-build-paths` - diff --git a/.agents/skills/next-best-practices/async-patterns.md b/.agents/skills/next-best-practices/async-patterns.md index dce8d8c..0692c4a 100644 --- a/.agents/skills/next-best-practices/async-patterns.md +++ b/.agents/skills/next-best-practices/async-patterns.md @@ -9,21 +9,18 @@ Always type them as `Promise<...>` and await them. ### Pages and Layouts ```tsx -type Props = { params: Promise<{ slug: string }> } +type Props = { params: Promise<{ slug: string }> }; export default async function Page({ params }: Props) { - const { slug } = await params + const { slug } = await params; } ``` ### Route Handlers ```tsx -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; } ``` @@ -31,13 +28,13 @@ export async function GET( ```tsx type Props = { - params: Promise<{ slug: string }> - searchParams: Promise<{ query?: string }> -} + params: Promise<{ slug: string }>; + searchParams: Promise<{ query?: string }>; +}; export default async function Page({ params, searchParams }: Props) { - const { slug } = await params - const { query } = await searchParams + const { slug } = await params; + const { query } = await searchParams; } ``` @@ -46,37 +43,37 @@ export default async function Page({ params, searchParams }: Props) { Use `React.use()` for non-async components: ```tsx -import { use } from 'react' +import { use } from 'react'; -type Props = { params: Promise<{ slug: string }> } +type Props = { params: Promise<{ slug: string }> }; export default function Page({ params }: Props) { - const { slug } = use(params) + const { slug } = use(params); } ``` ### generateMetadata ```tsx -type Props = { params: Promise<{ slug: string }> } +type Props = { params: Promise<{ slug: string }> }; export async function generateMetadata({ params }: Props): Promise { - const { slug } = await params - return { title: slug } + const { slug } = await params; + return { title: slug }; } ``` ## Async Cookies and Headers ```tsx -import { cookies, headers } from 'next/headers' +import { cookies, headers } from 'next/headers'; export default async function Page() { - const cookieStore = await cookies() - const headersList = await headers() + const cookieStore = await cookies(); + const headersList = await headers(); - const theme = cookieStore.get('theme') - const userAgent = headersList.get('user-agent') + const theme = cookieStore.get('theme'); + const userAgent = headersList.get('user-agent'); } ``` diff --git a/.agents/skills/next-best-practices/bundling.md b/.agents/skills/next-best-practices/bundling.md index ac5e814..19e3434 100644 --- a/.agents/skills/next-best-practices/bundling.md +++ b/.agents/skills/next-best-practices/bundling.md @@ -21,21 +21,21 @@ If the package is only needed on client: ```tsx // Bad: Fails - package uses window -import SomeChart from 'some-chart-library' +import SomeChart from 'some-chart-library'; export default function Page() { - return + return ; } // Good: Use dynamic import with ssr: false -import dynamic from 'next/dynamic' +import dynamic from 'next/dynamic'; const SomeChart = dynamic(() => import('some-chart-library'), { ssr: false, -}) +}); export default function Page() { - return + return ; } ``` @@ -47,10 +47,11 @@ For packages that should run on server but have bundling issues: // next.config.js module.exports = { serverExternalPackages: ['problematic-package'], -} +}; ``` Use this for: + - Packages with native bindings (sharp, bcrypt) - Packages that don't bundle well (some ORMs) - Packages with circular dependencies @@ -61,19 +62,19 @@ Wrap the entire usage in a client component: ```tsx // components/ChartWrapper.tsx -'use client' +'use client'; -import { Chart } from 'chart-library' +import { Chart } from 'chart-library'; export function ChartWrapper(props) { - return + return ; } // app/page.tsx (server component) -import { ChartWrapper } from '@/components/ChartWrapper' +import { ChartWrapper } from '@/components/ChartWrapper'; export default function Page() { - return + return ; } ``` @@ -83,13 +84,13 @@ Import CSS files instead of using `` tags. Next.js handles bundling and op ```tsx // Bad: Manual link tag - +; // Good: Import CSS -import './styles.css' +import './styles.css'; // Good: CSS Modules -import styles from './Button.module.css' +import styles from './Button.module.css'; ``` ## Polyfills @@ -121,21 +122,21 @@ Module not found: ESM packages need to be imported // next.config.js module.exports = { transpilePackages: ['some-esm-package', 'another-package'], -} +}; ``` ## Common Problematic Packages -| Package | Issue | Solution | -|---------|-------|----------| -| `sharp` | Native bindings | `serverExternalPackages: ['sharp']` | -| `bcrypt` | Native bindings | `serverExternalPackages: ['bcrypt']` or use `bcryptjs` | -| `canvas` | Native bindings | `serverExternalPackages: ['canvas']` | -| `recharts` | Uses window | `dynamic(() => import('recharts'), { ssr: false })` | -| `react-quill` | Uses document | `dynamic(() => import('react-quill'), { ssr: false })` | -| `mapbox-gl` | Uses window | `dynamic(() => import('mapbox-gl'), { ssr: false })` | -| `monaco-editor` | Uses window | `dynamic(() => import('@monaco-editor/react'), { ssr: false })` | -| `lottie-web` | Uses document | `dynamic(() => import('lottie-react'), { ssr: false })` | +| Package | Issue | Solution | +| --------------- | --------------- | --------------------------------------------------------------- | +| `sharp` | Native bindings | `serverExternalPackages: ['sharp']` | +| `bcrypt` | Native bindings | `serverExternalPackages: ['bcrypt']` or use `bcryptjs` | +| `canvas` | Native bindings | `serverExternalPackages: ['canvas']` | +| `recharts` | Uses window | `dynamic(() => import('recharts'), { ssr: false })` | +| `react-quill` | Uses document | `dynamic(() => import('react-quill'), { ssr: false })` | +| `mapbox-gl` | Uses window | `dynamic(() => import('mapbox-gl'), { ssr: false })` | +| `monaco-editor` | Uses window | `dynamic(() => import('@monaco-editor/react'), { ssr: false })` | +| `lottie-web` | Uses document | `dynamic(() => import('lottie-react'), { ssr: false })` | ## Bundle Analysis @@ -146,6 +147,7 @@ next experimental-analyze ``` This opens an interactive UI to: + - Filter by route, environment (client/server), and type - Inspect module sizes and import chains - View treemap visualization @@ -174,7 +176,7 @@ module.exports = { webpack: (config) => { // custom webpack config }, -} +}; ``` Reference: https://nextjs.org/docs/app/building-your-application/upgrading/from-webpack-to-turbopack diff --git a/.agents/skills/next-best-practices/data-patterns.md b/.agents/skills/next-best-practices/data-patterns.md index 8fc17f1..94ce5d6 100644 --- a/.agents/skills/next-best-practices/data-patterns.md +++ b/.agents/skills/next-best-practices/data-patterns.md @@ -33,17 +33,20 @@ async function UsersPage() { const users = await db.user.findMany(); // Or fetch from external API - const posts = await fetch('https://api.example.com/posts').then(r => r.json()); + const posts = await fetch('https://api.example.com/posts').then((r) => r.json()); return (
    - {users.map(user =>
  • {user.name}
  • )} + {users.map((user) => ( +
  • {user.name}
  • + ))}
); } ``` **Benefits**: + - No API to maintain - No client-server waterfall - Secrets stay on server @@ -89,12 +92,14 @@ export default function NewPost() { ``` **Benefits**: + - End-to-end type safety - Progressive enhancement (works without JS) - Automatic request handling - Integrated with React transitions **Constraints**: + - POST only (no GET caching semantics) - Internal use only (no external access) - Cannot return non-serializable data @@ -122,12 +127,14 @@ export async function POST(request: NextRequest) { ``` **When to use**: + - External API access (mobile apps, third parties) - Webhooks from external services - GET endpoints that need HTTP caching - OpenAPI/Swagger documentation needed **When NOT to use**: + - Internal data fetching (use Server Components) - Mutations from your UI (use Server Actions) @@ -138,8 +145,8 @@ export async function POST(request: NextRequest) { ```tsx // Bad: Sequential waterfalls async function Dashboard() { - const user = await getUser(); // Wait... - const posts = await getPosts(); // Then wait... + const user = await getUser(); // Wait... + const posts = await getPosts(); // Then wait... const comments = await getComments(); // Then wait... return
...
; @@ -151,11 +158,7 @@ async function Dashboard() { ```tsx // Good: Parallel fetching async function Dashboard() { - const [user, posts, comments] = await Promise.all([ - getUser(), - getPosts(), - getComments(), - ]); + const [user, posts, comments] = await Promise.all([getUser(), getPosts(), getComments()]); return
...
; } @@ -238,7 +241,7 @@ async function Page() { } // Client Component -'use client'; +('use client'); function ClientComponent({ initialData }) { const [data, setData] = useState(initialData); // ... @@ -256,7 +259,7 @@ function ClientComponent() { useEffect(() => { fetch('/api/data') - .then(r => r.json()) + .then((r) => r.json()) .then(setData); }, []); @@ -289,9 +292,9 @@ function ClientComponent() { ## Quick Reference -| Pattern | Use Case | HTTP Method | Caching | -|---------|----------|-------------|---------| -| Server Component fetch | Internal reads | Any | Full Next.js caching | -| Server Action | Mutations, form submissions | POST only | No | -| Route Handler | External APIs, webhooks | Any | GET can be cached | -| Client fetch to API | Client-side reads | Any | HTTP cache headers | +| Pattern | Use Case | HTTP Method | Caching | +| ---------------------- | --------------------------- | ----------- | -------------------- | +| Server Component fetch | Internal reads | Any | Full Next.js caching | +| Server Action | Mutations, form submissions | POST only | No | +| Route Handler | External APIs, webhooks | Any | GET can be cached | +| Client fetch to API | Client-side reads | Any | HTTP cache headers | diff --git a/.agents/skills/next-best-practices/debug-tricks.md b/.agents/skills/next-best-practices/debug-tricks.md index 9151ce6..33a74a2 100644 --- a/.agents/skills/next-best-practices/debug-tricks.md +++ b/.agents/skills/next-best-practices/debug-tricks.md @@ -35,42 +35,58 @@ curl -X POST http://localhost:/_next/mcp \ ### Available Tools #### `get_errors` + Get current errors from dev server (build errors, runtime errors with source-mapped stacks): + ```json { "name": "get_errors", "arguments": {} } ``` #### `get_routes` + Discover all routes by scanning filesystem: + ```json { "name": "get_routes", "arguments": {} } // Optional: { "name": "get_routes", "arguments": { "routerType": "app" } } ``` + Returns: `{ "appRouter": ["/", "/api/users/[id]", ...], "pagesRouter": [...] }` #### `get_project_metadata` + Get project path and dev server URL: + ```json { "name": "get_project_metadata", "arguments": {} } ``` + Returns: `{ "projectPath": "/path/to/project", "devServerUrl": "http://localhost:3000" }` #### `get_page_metadata` + Get runtime metadata about current page render (requires active browser session): + ```json { "name": "get_page_metadata", "arguments": {} } ``` + Returns segment trie data showing layouts, boundaries, and page components. #### `get_logs` + Get path to Next.js development log file: + ```json { "name": "get_logs", "arguments": {} } ``` + Returns path to `/logs/next-development.log` #### `get_server_action_by_id` + Locate a Server Action by ID: + ```json { "name": "get_server_action_by_id", "arguments": { "actionId": "" } } ``` @@ -100,6 +116,7 @@ next build --debug-build-paths "/blog/[slug]" ``` Use this to: + - Quickly verify a build fix without full rebuild - Debug static generation issues for specific pages - Iterate faster on build errors diff --git a/.agents/skills/next-best-practices/directives.md b/.agents/skills/next-best-practices/directives.md index 1ea1637..23b5871 100644 --- a/.agents/skills/next-best-practices/directives.md +++ b/.agents/skills/next-best-practices/directives.md @@ -7,18 +7,19 @@ These are React directives, not Next.js specific. ### `'use client'` Marks a component as a Client Component. Required for: + - React hooks (`useState`, `useEffect`, etc.) - Event handlers (`onClick`, `onChange`) - Browser APIs (`window`, `localStorage`) ```tsx -'use client' +'use client'; -import { useState } from 'react' +import { useState } from 'react'; export function Counter() { - const [count, setCount] = useState(0) - return + const [count, setCount] = useState(0); + return ; } ``` @@ -29,7 +30,7 @@ Reference: https://react.dev/reference/rsc/use-client Marks a function as a Server Action. Can be passed to Client Components. ```tsx -'use server' +'use server'; export async function submitForm(formData: FormData) { // Runs on server @@ -41,10 +42,10 @@ Or inline within a Server Component: ```tsx export default function Page() { async function submit() { - 'use server' + 'use server'; // Runs on server } - return
...
+ return
...
; } ``` @@ -59,10 +60,10 @@ Reference: https://react.dev/reference/rsc/use-server Marks a function or component for caching. Part of Next.js Cache Components. ```tsx -'use cache' +'use cache'; export async function getCachedData() { - return await fetchData() + return await fetchData(); } ``` diff --git a/.agents/skills/next-best-practices/error-handling.md b/.agents/skills/next-best-practices/error-handling.md index 663e37b..f880e25 100644 --- a/.agents/skills/next-best-practices/error-handling.md +++ b/.agents/skills/next-best-practices/error-handling.md @@ -11,21 +11,15 @@ Reference: https://nextjs.org/docs/app/getting-started/error-handling Catches errors in a route segment and its children: ```tsx -'use client' +'use client'; -export default function Error({ - error, - reset, -}: { - error: Error & { digest?: string } - reset: () => void -}) { +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { return (

Something went wrong!

- ) + ); } ``` @@ -36,15 +30,9 @@ export default function Error({ Catches errors in root layout: ```tsx -'use client' +'use client'; -export default function GlobalError({ - error, - reset, -}: { - error: Error & { digest?: string } - reset: () => void -}) { +export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { return ( @@ -52,7 +40,7 @@ export default function GlobalError({ - ) + ); } ``` @@ -107,6 +95,7 @@ async function createPost(formData: FormData) { ``` Same applies to: + - `redirect()` - 307 temporary redirect - `permanentRedirect()` - 308 permanent redirect - `notFound()` - 404 not found @@ -116,15 +105,15 @@ Same applies to: Use `unstable_rethrow()` to re-throw these errors in catch blocks: ```tsx -import { unstable_rethrow } from 'next/navigation' +import { unstable_rethrow } from 'next/navigation'; async function action() { try { // ... - redirect('/success') + redirect('/success'); } catch (error) { - unstable_rethrow(error) // Re-throws Next.js internal errors - return { error: 'Something went wrong' } + unstable_rethrow(error); // Re-throws Next.js internal errors + return { error: 'Something went wrong' }; } } ``` @@ -132,13 +121,13 @@ async function action() { ## Redirects ```tsx -import { redirect, permanentRedirect } from 'next/navigation' +import { redirect, permanentRedirect } from 'next/navigation'; // 307 Temporary - use for most cases -redirect('/new-path') +redirect('/new-path'); // 308 Permanent - use for URL migrations (cached by browsers) -permanentRedirect('/new-url') +permanentRedirect('/new-url'); ``` ## Auth Errors @@ -146,20 +135,20 @@ permanentRedirect('/new-url') Trigger auth-related error pages: ```tsx -import { forbidden, unauthorized } from 'next/navigation' +import { forbidden, unauthorized } from 'next/navigation'; async function Page() { - const session = await getSession() + const session = await getSession(); if (!session) { - unauthorized() // Renders unauthorized.tsx (401) + unauthorized(); // Renders unauthorized.tsx (401) } if (!session.hasAccess) { - forbidden() // Renders forbidden.tsx (403) + forbidden(); // Renders forbidden.tsx (403) } - return + return ; } ``` @@ -168,12 +157,12 @@ Create corresponding error pages: ```tsx // app/forbidden.tsx export default function Forbidden() { - return
You don't have access to this resource
+ return
You don't have access to this resource
; } // app/unauthorized.tsx export default function Unauthorized() { - return
Please log in to continue
+ return
Please log in to continue
; } ``` @@ -190,24 +179,24 @@ export default function NotFound() {

Not Found

Could not find the requested resource

- ) + ); } ``` ### Triggering Not Found ```tsx -import { notFound } from 'next/navigation' +import { notFound } from 'next/navigation'; export default async function Page({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const post = await getPost(id) + const { id } = await params; + const post = await getPost(id); if (!post) { - notFound() // Renders closest not-found.tsx + notFound(); // Renders closest not-found.tsx } - return
{post.title}
+ return
{post.title}
; } ``` diff --git a/.agents/skills/next-best-practices/file-conventions.md b/.agents/skills/next-best-practices/file-conventions.md index c2b3b40..58500e0 100644 --- a/.agents/skills/next-best-practices/file-conventions.md +++ b/.agents/skills/next-best-practices/file-conventions.md @@ -27,16 +27,16 @@ app/ ## Special Files -| File | Purpose | -|------|---------| -| `page.tsx` | UI for a route segment | -| `layout.tsx` | Shared UI for segment and children | -| `loading.tsx` | Loading UI (Suspense boundary) | -| `error.tsx` | Error UI (Error boundary) | -| `not-found.tsx` | 404 UI | -| `route.ts` | API endpoint | -| `template.tsx` | Like layout but re-renders on navigation | -| `default.tsx` | Fallback for parallel routes | +| File | Purpose | +| --------------- | ---------------------------------------- | +| `page.tsx` | UI for a route segment | +| `layout.tsx` | Shared UI for segment and children | +| `loading.tsx` | Loading UI (Suspense boundary) | +| `error.tsx` | Error UI (Error boundary) | +| `not-found.tsx` | 404 UI | +| `route.ts` | API endpoint | +| `template.tsx` | Like layout but re-renders on navigation | +| `default.tsx` | Fallback for parallel routes | ## Route Segments @@ -74,6 +74,7 @@ app/ ``` Conventions: + - `(.)` - same level - `(..)` - one level up - `(..)(..)` - two levels up @@ -128,10 +129,10 @@ export const proxyConfig = { }; ``` -| Version | File | Export | Config | -|---------|------|--------|--------| -| v14-15 | `middleware.ts` | `middleware()` | `config` | -| v16+ | `proxy.ts` | `proxy()` | `proxyConfig` | +| Version | File | Export | Config | +| ------- | --------------- | -------------- | ------------- | +| v14-15 | `middleware.ts` | `middleware()` | `config` | +| v16+ | `proxy.ts` | `proxy()` | `proxyConfig` | **Migration**: Run `npx @next/codemod@latest upgrade` to auto-rename. diff --git a/.agents/skills/next-best-practices/font.md b/.agents/skills/next-best-practices/font.md index 7e52685..0b9be72 100644 --- a/.agents/skills/next-best-practices/font.md +++ b/.agents/skills/next-best-practices/font.md @@ -6,44 +6,45 @@ Use `next/font` for automatic font optimization with zero layout shift. ```tsx // app/layout.tsx -import { Inter } from 'next/font/google' +import { Inter } from 'next/font/google'; -const inter = Inter({ subsets: ['latin'] }) +const inter = Inter({ subsets: ['latin'] }); export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} - ) + ); } ``` ## Multiple Fonts ```tsx -import { Inter, Roboto_Mono } from 'next/font/google' +import { Inter, Roboto_Mono } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], variable: '--font-inter', -}) +}); const robotoMono = Roboto_Mono({ subsets: ['latin'], variable: '--font-roboto-mono', -}) +}); export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} - ) + ); } ``` Use in CSS: + ```css body { font-family: var(--font-inter); @@ -61,35 +62,35 @@ code { const inter = Inter({ subsets: ['latin'], weight: '400', -}) +}); // Multiple weights const inter = Inter({ subsets: ['latin'], weight: ['400', '500', '700'], -}) +}); // Variable font (recommended) - includes all weights const inter = Inter({ subsets: ['latin'], // No weight needed - variable fonts support all weights -}) +}); // With italic const inter = Inter({ subsets: ['latin'], style: ['normal', 'italic'], -}) +}); ``` ## Local Fonts ```tsx -import localFont from 'next/font/local' +import localFont from 'next/font/local'; const myFont = localFont({ src: './fonts/MyFont.woff2', -}) +}); // Multiple files for different weights const myFont = localFont({ @@ -105,32 +106,32 @@ const myFont = localFont({ style: 'normal', }, ], -}) +}); // Variable font const myFont = localFont({ src: './fonts/MyFont-Variable.woff2', variable: '--font-my-font', -}) +}); ``` ## Tailwind CSS Integration ```tsx // app/layout.tsx -import { Inter } from 'next/font/google' +import { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], variable: '--font-inter', -}) +}); export default function RootLayout({ children }) { return ( {children} - ) + ); } ``` @@ -144,7 +145,7 @@ module.exports = { }, }, }, -} +}; ``` ## Preloading Subsets @@ -153,10 +154,10 @@ Only load needed character subsets: ```tsx // Latin only (most common) -const inter = Inter({ subsets: ['latin'] }) +const inter = Inter({ subsets: ['latin'] }); // Multiple subsets -const inter = Inter({ subsets: ['latin', 'latin-ext', 'cyrillic'] }) +const inter = Inter({ subsets: ['latin', 'latin-ext', 'cyrillic'] }); ``` ## Display Strategy @@ -167,7 +168,7 @@ Control font loading behavior: const inter = Inter({ subsets: ['latin'], display: 'swap', // Default - shows fallback, swaps when loaded -}) +}); // Options: // 'auto' - browser decides @@ -231,15 +232,15 @@ const inter = Inter({ subsets: ['latin'] }) ```tsx // For component-specific fonts, export from a shared file // lib/fonts.ts -import { Inter, Playfair_Display } from 'next/font/google' +import { Inter, Playfair_Display } from 'next/font/google'; -export const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }) -export const playfair = Playfair_Display({ subsets: ['latin'], variable: '--font-playfair' }) +export const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }); +export const playfair = Playfair_Display({ subsets: ['latin'], variable: '--font-playfair' }); // components/Heading.tsx -import { playfair } from '@/lib/fonts' +import { playfair } from '@/lib/fonts'; export function Heading({ children }) { - return

{children}

+ return

{children}

; } ``` diff --git a/.agents/skills/next-best-practices/functions.md b/.agents/skills/next-best-practices/functions.md index 8f28a8b..b84c35d 100644 --- a/.agents/skills/next-best-practices/functions.md +++ b/.agents/skills/next-best-practices/functions.md @@ -6,45 +6,45 @@ Reference: https://nextjs.org/docs/app/api-reference/functions ## Navigation Hooks (Client) -| Hook | Purpose | Reference | -|------|---------|-----------| -| `useRouter` | Programmatic navigation (`push`, `replace`, `back`, `refresh`) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-router) | -| `usePathname` | Get current pathname | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-pathname) | -| `useSearchParams` | Read URL search parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-search-params) | -| `useParams` | Access dynamic route parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-params) | -| `useSelectedLayoutSegment` | Active child segment (one level) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segment) | -| `useSelectedLayoutSegments` | All active segments below layout | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segments) | -| `useLinkStatus` | Check link prefetch status | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-link-status) | -| `useReportWebVitals` | Report Core Web Vitals metrics | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals) | +| Hook | Purpose | Reference | +| --------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `useRouter` | Programmatic navigation (`push`, `replace`, `back`, `refresh`) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-router) | +| `usePathname` | Get current pathname | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-pathname) | +| `useSearchParams` | Read URL search parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-search-params) | +| `useParams` | Access dynamic route parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-params) | +| `useSelectedLayoutSegment` | Active child segment (one level) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segment) | +| `useSelectedLayoutSegments` | All active segments below layout | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segments) | +| `useLinkStatus` | Check link prefetch status | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-link-status) | +| `useReportWebVitals` | Report Core Web Vitals metrics | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals) | ## Server Functions -| Function | Purpose | Reference | -|----------|---------|-----------| -| `cookies` | Read/write cookies | [Docs](https://nextjs.org/docs/app/api-reference/functions/cookies) | -| `headers` | Read request headers | [Docs](https://nextjs.org/docs/app/api-reference/functions/headers) | -| `draftMode` | Enable preview of unpublished CMS content | [Docs](https://nextjs.org/docs/app/api-reference/functions/draft-mode) | -| `after` | Run code after response finishes streaming | [Docs](https://nextjs.org/docs/app/api-reference/functions/after) | +| Function | Purpose | Reference | +| ------------ | -------------------------------------------- | ---------------------------------------------------------------------- | +| `cookies` | Read/write cookies | [Docs](https://nextjs.org/docs/app/api-reference/functions/cookies) | +| `headers` | Read request headers | [Docs](https://nextjs.org/docs/app/api-reference/functions/headers) | +| `draftMode` | Enable preview of unpublished CMS content | [Docs](https://nextjs.org/docs/app/api-reference/functions/draft-mode) | +| `after` | Run code after response finishes streaming | [Docs](https://nextjs.org/docs/app/api-reference/functions/after) | | `connection` | Wait for connection before dynamic rendering | [Docs](https://nextjs.org/docs/app/api-reference/functions/connection) | -| `userAgent` | Parse User-Agent header | [Docs](https://nextjs.org/docs/app/api-reference/functions/userAgent) | +| `userAgent` | Parse User-Agent header | [Docs](https://nextjs.org/docs/app/api-reference/functions/userAgent) | ## Generate Functions -| Function | Purpose | Reference | -|----------|---------|-----------| -| `generateStaticParams` | Pre-render dynamic routes at build time | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) | -| `generateMetadata` | Dynamic metadata | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) | -| `generateViewport` | Dynamic viewport config | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-viewport) | -| `generateSitemaps` | Multiple sitemaps for large sites | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps) | -| `generateImageMetadata` | Multiple OG images per route | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-image-metadata) | +| Function | Purpose | Reference | +| ----------------------- | --------------------------------------- | ----------------------------------------------------------------------------------- | +| `generateStaticParams` | Pre-render dynamic routes at build time | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) | +| `generateMetadata` | Dynamic metadata | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) | +| `generateViewport` | Dynamic viewport config | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-viewport) | +| `generateSitemaps` | Multiple sitemaps for large sites | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps) | +| `generateImageMetadata` | Multiple OG images per route | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-image-metadata) | ## Request/Response -| Function | Purpose | Reference | -|----------|---------|-----------| -| `NextRequest` | Extended Request with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-request) | -| `NextResponse` | Extended Response with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-response) | -| `ImageResponse` | Generate OG images | [Docs](https://nextjs.org/docs/app/api-reference/functions/image-response) | +| Function | Purpose | Reference | +| --------------- | ------------------------------ | -------------------------------------------------------------------------- | +| `NextRequest` | Extended Request with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-request) | +| `NextResponse` | Extended Response with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-response) | +| `ImageResponse` | Generate OG images | [Docs](https://nextjs.org/docs/app/api-reference/functions/image-response) | ## Common Examples @@ -54,30 +54,30 @@ Use `next/link` for internal navigation instead of `` tags. ```tsx // Bad: Plain anchor tag -About +About; // Good: Next.js Link -import Link from 'next/link' +import Link from 'next/link'; -About +About; ``` Active link styling: ```tsx -'use client' +'use client'; -import Link from 'next/link' -import { usePathname } from 'next/navigation' +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; export function NavLink({ href, children }) { - const pathname = usePathname() + const pathname = usePathname(); return ( {children} - ) + ); } ``` @@ -86,23 +86,23 @@ export function NavLink({ href, children }) { ```tsx // app/blog/[slug]/page.tsx export async function generateStaticParams() { - const posts = await getPosts() - return posts.map((post) => ({ slug: post.slug })) + const posts = await getPosts(); + return posts.map((post) => ({ slug: post.slug })); } ``` ### After Response ```tsx -import { after } from 'next/server' +import { after } from 'next/server'; export async function POST(request: Request) { - const data = await processRequest(request) + const data = await processRequest(request); after(async () => { - await logAnalytics(data) - }) + await logAnalytics(data); + }); - return Response.json({ success: true }) + return Response.json({ success: true }); } ``` diff --git a/.agents/skills/next-best-practices/hydration-error.md b/.agents/skills/next-best-practices/hydration-error.md index 36d4829..a49bebe 100644 --- a/.agents/skills/next-best-practices/hydration-error.md +++ b/.agents/skills/next-best-practices/hydration-error.md @@ -17,16 +17,16 @@ In development, click the hydration error to see the server/client diff. ```tsx // Bad: Causes mismatch - window doesn't exist on server -
{window.innerWidth}
+
{window.innerWidth}
; // Good: Use client component with mounted check -'use client' -import { useState, useEffect } from 'react' +('use client'); +import { useState, useEffect } from 'react'; export function ClientOnly({ children }: { children: React.ReactNode }) { - const [mounted, setMounted] = useState(false) - useEffect(() => setMounted(true), []) - return mounted ? children : null + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return mounted ? children : null; } ``` @@ -36,12 +36,12 @@ Server and client may be in different timezones: ```tsx // Bad: Causes mismatch -{new Date().toLocaleString()} +{new Date().toLocaleString()}; // Good: Render on client only -'use client' -const [time, setTime] = useState() -useEffect(() => setTime(new Date().toLocaleString()), []) +('use client'); +const [time, setTime] = useState(); +useEffect(() => setTime(new Date().toLocaleString()), []); ``` ### Random Values or IDs @@ -78,14 +78,9 @@ Scripts that modify DOM during hydration. ```tsx // Good: Use next/script with afterInteractive -import Script from 'next/script' +import Script from 'next/script'; export default function Page() { - return ( - +; // Good: Next.js Script component -import Script from 'next/script' +import Script from 'next/script'; -` | -| **Then** | ✅ Script ไม่ถูก Execute ใน Browser | -| | ✅ แสดงเป็น Plaintext หรือถูก Escaped | +| | Description | +| --------- | ------------------------------------- | +| **Given** | ช่อง Input เช่น Subject, Comment | +| **When** | ใส่ `` | +| **Then** | ✅ Script ไม่ถูก Execute ใน Browser | +| | ✅ แสดงเป็น Plaintext หรือถูก Escaped | ### AC-SEC-003 — Authorization Boundary (IDOR Protection) + **Priority:** 🔴 Blocker | **Role:** QA (Security Test) -| | Description | -|---|---| -| **Given** | User A รู้ Document ID ของ User B | -| **When** | User A เรียก `GET /correspondences/:id` ของ User B โดยตรง | -| **Then** | ✅ ได้รับ 403 Forbidden (ไม่ใช่ 404) | -| | ✅ ข้อมูลของ User B ไม่ถูกเปิดเผย | +| | Description | +| --------- | --------------------------------------------------------- | +| **Given** | User A รู้ Document ID ของ User B | +| **When** | User A เรียก `GET /correspondences/:id` ของ User B โดยตรง | +| **Then** | ✅ ได้รับ 403 Forbidden (ไม่ใช่ 404) | +| | ✅ ข้อมูลของ User B ไม่ถูกเปิดเผย | ### AC-SEC-004 — Rate Limiting on Auth Endpoint + **Priority:** 🔴 Blocker | **Role:** QA (Security Test) -| | Description | -|---|---| -| **Given** | ไม่มี Session อยู่ | -| **When** | เรียก `POST /auth/login` เกิน 5 ครั้งใน 1 นาที จาก IP เดียว | -| **Then** | ✅ ครั้งที่ 6+ ได้รับ 429 Too Many Requests | -| | ✅ Audit Log บันทึก Rate Limit Event | +| | Description | +| --------- | ----------------------------------------------------------- | +| **Given** | ไม่มี Session อยู่ | +| **When** | เรียก `POST /auth/login` เกิน 5 ครั้งใน 1 นาที จาก IP เดียว | +| **Then** | ✅ ครั้งที่ 6+ ได้รับ 429 Too Many Requests | +| | ✅ Audit Log บันทึก Rate Limit Event | ### AC-SEC-005 — Security Headers + **Priority:** 🟠 Critical | **Role:** QA -| | Description | -|---|---| -| **Given** | ระบบทำงานบน HTTPS | -| **When** | ตรวจสอบ HTTP Response Headers | -| **Then** | ✅ `X-Content-Type-Options: nosniff` | -| | ✅ `X-Frame-Options: DENY` | -| | ✅ `Strict-Transport-Security` present | -| | ✅ `Content-Security-Policy` defined | +| | Description | +| --------- | -------------------------------------- | +| **Given** | ระบบทำงานบน HTTPS | +| **When** | ตรวจสอบ HTTP Response Headers | +| **Then** | ✅ `X-Content-Type-Options: nosniff` | +| | ✅ `X-Frame-Options: DENY` | +| | ✅ `Strict-Transport-Security` present | +| | ✅ `Content-Security-Policy` defined | --- ## ⚡ Section 15: Performance Acceptance Criteria ### AC-PERF-001 — API Response Time + **Priority:** 🟠 Critical -| | Description | -|---|---| -| **Test Condition** | 50 concurrent users, Normal workload | -| **Then** | ✅ P90 Response Time < 200ms สำหรับ CRUD operations | -| | ✅ P90 Search Query < 500ms | -| | ✅ File Upload 50MB < 30 seconds | +| | Description | +| ------------------ | --------------------------------------------------- | +| **Test Condition** | 50 concurrent users, Normal workload | +| **Then** | ✅ P90 Response Time < 200ms สำหรับ CRUD operations | +| | ✅ P90 Search Query < 500ms | +| | ✅ File Upload 50MB < 30 seconds | > **Test Tool:** k6 หรือ Apache JMeter > **Test Script:** `specs/05-Engineering-Guidelines/performance-test-script.js` (TODO: สร้าง) ### AC-PERF-002 — Concurrent Users + **Priority:** 🟠 Critical -| | Description | -|---|---| -| **Test Condition** | 100 concurrent active users | -| **Then** | ✅ ระบบไม่ crash หรือ Error Rate > 1% | -| | ✅ Memory ไม่เกิน 80% ของ Container Limit | -| | ✅ CPU ไม่เกิน 80% sustained | +| | Description | +| ------------------ | ----------------------------------------- | +| **Test Condition** | 100 concurrent active users | +| **Then** | ✅ ระบบไม่ crash หรือ Error Rate > 1% | +| | ✅ Memory ไม่เกิน 80% ของ Container Limit | +| | ✅ CPU ไม่เกิน 80% sustained | ### AC-PERF-003 — Document Number Concurrent + **Priority:** 🔴 Blocker -| | Description | -|---|---| +| | Description | +| ------------------ | ----------------------------------------------------------------------------- | | **Test Condition** | 50 concurrent POST `/document-numbering/reserve` สำหรับ Project/Type เดียวกัน | -| **Then** | ✅ เลขเอกสารไม่ซ้ำกันทั้ง 50 Request | -| | ✅ ทุก Request สำเร็จ (ไม่มี 5xx Error) | +| **Then** | ✅ เลขเอกสารไม่ซ้ำกันทั้ง 50 Request | +| | ✅ ทุก Request สำเร็จ (ไม่มี 5xx Error) | --- ## 💾 Section 16: Data Integrity & Recovery ### AC-DATA-001 — Backup & Restore + **Priority:** 🔴 Blocker | **Role:** DevOps -| | Description | -|---|---| -| **Given** | Production Database มีข้อมูล 1 วันทำการ | -| **When** | ทดสอบ Restore จาก Backup ล่าสุด | -| **Then** | ✅ Restore สำเร็จภายใน RTO < 4 ชั่วโมง | -| | ✅ ข้อมูลสมบูรณ์ ไม่มี Data Loss เกิน RPO < 1 ชั่วโมง | -| | ✅ ระบบทำงานปกติหลัง Restore | +| | Description | +| --------- | ----------------------------------------------------- | +| **Given** | Production Database มีข้อมูล 1 วันทำการ | +| **When** | ทดสอบ Restore จาก Backup ล่าสุด | +| **Then** | ✅ Restore สำเร็จภายใน RTO < 4 ชั่วโมง | +| | ✅ ข้อมูลสมบูรณ์ ไม่มี Data Loss เกิน RPO < 1 ชั่วโมง | +| | ✅ ระบบทำงานปกติหลัง Restore | ### AC-DATA-002 — Orphan File Prevention + **Priority:** 🟠 Critical | **Role:** ระบบ -| | Description | -|---|---| +| | Description | +| --------- | ------------------------------------------------------------ | | **Given** | User อัปโหลดไฟล์ไปยัง Temp แล้ว Cancel Document ก่อน Confirm | -| **When** | Cleanup Job ทำงาน | -| **Then** | ✅ ไฟล์ใน Temp Storage ถูกลบ | -| | ✅ Permanent Storage ไม่มี Orphan Files | +| **When** | Cleanup Job ทำงาน | +| **Then** | ✅ ไฟล์ใน Temp Storage ถูกลบ | +| | ✅ Permanent Storage ไม่มี Orphan Files | --- ## ✅ UAT Sign-off Checklist ### Pre-UAT Conditions + - [ ] ระบบ Deploy บน Staging Environment (ใช้ Docker Compose เหมือน Production) - [ ] Seed Data ตาม `lcbp3-v1.8.0-seed-basic.sql` และ `seed-permissions.sql` - [ ] Test Users ทุก Role ถูกสร้าง (Superadmin, Org Admin, Document Control, Editor, Viewer) @@ -727,31 +788,31 @@ related: ### Go-Live Criteria (ต้องผ่านทั้งหมด) -| # | Criteria | Status | -|---|----------|--------| -| 1 | AC-AUTH-001 ~ AC-AUTH-005 ผ่านทั้งหมด | ⬜ | -| 2 | AC-ADMIN-001 ~ AC-ADMIN-005 ผ่านทั้งหมด | ⬜ | -| 3 | AC-CORR-001 ~ AC-CORR-002 (Happy Path) ผ่าน | ⬜ | -| 4 | AC-RFA-001 ~ AC-RFA-003 (Submit + Approve) ผ่าน | ⬜ | -| 5 | AC-WF-001 ~ AC-WF-003 (Workflow Engine Core) ผ่าน | ⬜ | -| 6 | AC-DN-001 (Concurrent Number) ผ่าน | ⬜ | -| 7 | AC-STOR-001 (Two-Phase Storage + ClamAV) ผ่าน | ⬜ | -| 8 | AC-SEC-001 ~ AC-SEC-004 (Security) ผ่านทั้งหมด | ⬜ | -| 9 | AC-PERF-001 (Response Time) ผ่าน | ⬜ | -| 10 | AC-DATA-001 (Backup & Restore DR Test) ผ่าน | ⬜ | -| 11 | AC-AUDIT-001 (Audit Log Coverage) ผ่าน | ⬜ | -| 12 | ไม่มี Bug Priority P0/P1 ค้างอยู่ | ⬜ | +| # | Criteria | Status | +| --- | ------------------------------------------------- | ------ | +| 1 | AC-AUTH-001 ~ AC-AUTH-005 ผ่านทั้งหมด | ⬜ | +| 2 | AC-ADMIN-001 ~ AC-ADMIN-005 ผ่านทั้งหมด | ⬜ | +| 3 | AC-CORR-001 ~ AC-CORR-002 (Happy Path) ผ่าน | ⬜ | +| 4 | AC-RFA-001 ~ AC-RFA-003 (Submit + Approve) ผ่าน | ⬜ | +| 5 | AC-WF-001 ~ AC-WF-003 (Workflow Engine Core) ผ่าน | ⬜ | +| 6 | AC-DN-001 (Concurrent Number) ผ่าน | ⬜ | +| 7 | AC-STOR-001 (Two-Phase Storage + ClamAV) ผ่าน | ⬜ | +| 8 | AC-SEC-001 ~ AC-SEC-004 (Security) ผ่านทั้งหมด | ⬜ | +| 9 | AC-PERF-001 (Response Time) ผ่าน | ⬜ | +| 10 | AC-DATA-001 (Backup & Restore DR Test) ผ่าน | ⬜ | +| 11 | AC-AUDIT-001 (Audit Log Coverage) ผ่าน | ⬜ | +| 12 | ไม่มี Bug Priority P0/P1 ค้างอยู่ | ⬜ | ### UAT Participant Sign-off -| Organization | Representative | Signature | Date | -|-------------|----------------|-----------|------| -| กทท. (Owner) | | | | -| สค. (Admin) | | | | -| TEAM (Design Consultant) | | | | -| คคง. (Supervisor) | | | | -| ผรม. (Contractor Rep.) | | | | -| NAP (Developer) | Nattanin P. | | | +| Organization | Representative | Signature | Date | +| ------------------------ | -------------- | --------- | ---- | +| กทท. (Owner) | | | | +| สค. (Admin) | | | | +| TEAM (Design Consultant) | | | | +| คคง. (Supervisor) | | | | +| ผรม. (Contractor Rep.) | | | | +| NAP (Developer) | Nattanin P. | | | --- diff --git a/specs/01-requirements/01-06-edge-cases-and-rules.md b/specs/01-requirements/01-06-edge-cases-and-rules.md index 9cc2159..f64ca70 100644 --- a/specs/01-requirements/01-06-edge-cases-and-rules.md +++ b/specs/01-requirements/01-06-edge-cases-and-rules.md @@ -1,17 +1,20 @@ # 🛡️ Module Edge Cases & Business Rules — LCBP3-DMS v1.8.0 --- + title: 'Edge Cases, Business Rules, and Anti-Bug Specifications' version: 1.0.0 status: DRAFT owner: Nattanin Peancharoen (Product Owner / System Architect) last_updated: 2026-03-11 related: - - specs/01-Requirements/01-05-acceptance-criteria.md - - specs/01-Requirements/01-02-business-rules/01-02-02-doc-numbering-rules.md - - specs/06-Decision-Records/ADR-001-unified-workflow-engine.md - - specs/06-Decision-Records/ADR-016-security-authentication.md - - specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql + +- specs/01-Requirements/01-05-acceptance-criteria.md +- specs/01-Requirements/01-02-business-rules/01-02-02-doc-numbering-rules.md +- specs/06-Decision-Records/ADR-001-unified-workflow-engine.md +- specs/06-Decision-Records/ADR-016-security-authentication.md +- specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql + --- > [!IMPORTANT] @@ -31,16 +34,19 @@ related: ## Module 1: Document Numbering Edge Cases ### EC-DN-001 — Concurrent Submission (Race Condition) + **Severity:** 🔴 Critical | **Type:** Concurrency, Data Integrity **Scenario:** User A และ User B กด Submit Correspondence พร้อมกันทุก millisecond สำหรับ Project/Type/Sender/Receiver เดียวกัน **Expected Behavior:** + - ทั้งสองได้รับเลขเอกสาร **ต่างกัน** (เช่น 0001 และ 0002) - ไม่มีเลข Duplicate ในระบบ - API ทั้งสองตอบ 201 Created สำเร็จ **Implementation Rule:** + ``` 1. Redis Redlock acquire บน counterKey ก่อน 2. ถ้า Lock ไม่ได้ใน 5 วินาที → 503 Service Unavailable (Retry-After: 3s) @@ -54,20 +60,24 @@ related: --- ### EC-DN-002 — Yearly Reset Boundary Condition + **Severity:** 🟠 High | **Type:** Business Rule, Data Integrity **Scenario A:** Document ถูก Submit เวลา 23:59:59 วันที่ 31 ธันวาคม **Scenario B:** Cron Job Reset Counter ทำงานตอนเที่ยงคืน แต่มี Document ในสถานะ RESERVED อยู่ **Expected Behavior (A):** + - ได้รับเลขของปีเก่า (counter ปีเก่า) — เวลา Submit คือที่กำหนด - ถ้า Confirm หลังเที่ยงคืน → เลขยังเป็นของปีเก่า (ใช้เวลา Reserve ไม่ใช่ Confirm) **Expected Behavior (B):** + - Cron Job ต้อง **Skip** เลขที่อยู่ใน RESERVED state — ไม่ Reset Counter จนกว่า Reservation จะ Expire หรือ Confirmed - ถ้า Reset รันก่อน Expiry: Counter ใหม่เริ่ม 0001 แต่ Reserved เลขยังคงอยู่ (ไม่ถูก Overwrite) **Implementation Rule:** + ``` - Cron Job ติด Lock เดียวกับ Reserve Process ก่อน Reset - Reset scope = 'YEAR_2025' → Counter Key ใหม่ = 'YEAR_2026' @@ -77,11 +87,13 @@ related: --- ### EC-DN-003 — Cancelled/Voided Number Must Not Reuse + **Severity:** 🔴 Critical | **Type:** Business Rule, Data Integrity **Scenario:** Document ถูก Submit → ได้เลข 0005 → Admin Cancel Document → User Submit ใหม่ **Expected Behavior:** + - เลขถัดไปต้อง **0006** ไม่ใช่ 0005 - เลข 0005 อยู่ใน `document_number_reservations` สถานะ CANCELLED ตลอดไป - ไม่มีการ Reuse เลขที่ถูก Cancel เด็ดขาด @@ -91,16 +103,19 @@ related: --- ### EC-DN-004 — Reservation TTL Expired Cleanup + **Severity:** 🟠 High | **Type:** Data Integrity, UX **Scenario:** User Reserve เลข (TTL 5 นาที) แต่ Browser ปิดก่อน Confirm **Expected Behavior:** + - หลัง 5 นาที → `document_number_reservations.status` เปลี่ยนเป็น EXPIRED (by Cron/TTL) - Counter ไม่ถูก Decrement (เลขนั้นหายไปถาวร — ฟัน-หลอ-เลข เป็นที่ยอมรับ) - ถ้า User กลับมา Confirm Token ที่ Expired → 410 Gone (Token expired) **Implementation Rule:** + ```sql -- Cron ทุก 1 นาที UPDATE document_number_reservations @@ -111,11 +126,13 @@ WHERE status = 'RESERVED' AND expires_at < NOW(); --- ### EC-DN-005 — Idempotency Key Duplicate Submission + **Severity:** 🟠 High | **Type:** Concurrency, UX **Scenario:** Network ไม่เสถียร → User คลิก Submit 2 ครั้ง → Frontend ส่ง POST 2 ครั้งด้วย Idempotency-Key เดียวกัน **Expected Behavior:** + - Request แรก → ออกเลขใหม่ → 201 Created - Request ที่สอง (same Idempotency-Key) → **Return เลขเดิม** → 200 OK (ไม่ออกเลขใหม่) - ไม่ว่า Request ที่สองจะมาเร็วแค่ไหน @@ -127,17 +144,20 @@ WHERE status = 'RESERVED' AND expires_at < NOW(); ## Module 2: Workflow Engine Edge Cases ### EC-WF-001 — Concurrent Approval (Parallel Steps) + **Severity:** 🔴 Critical | **Type:** Concurrency, Business Rule **Scenario:** Workflow มี Parallel Approval (Engineer A **และ** Engineer B ต้อง Approve พร้อมกัน) Engineer A Approve พร้อมกับ Engineer B Approve ใน millisecond เดียวกัน **Expected Behavior:** + - Workflow System บันทึกทั้งสอง Action อย่างถูกต้อง - State เปลี่ยนเป็น "Approved" ก็ต่อเมื่อ **ทุก Parallel Branch** Complete แล้ว - ไม่เกิด State Corruption (เช่น State ถูก Override โดย Action หนึ่ง) **Implementation Rule:** + ``` - DB Transaction Isolation: SERIALIZABLE สำหรับ State Transition - Check: all parallel branches completed → ถ้าใช่ → advance to next state @@ -147,16 +167,19 @@ Engineer A Approve พร้อมกับ Engineer B Approve ใน millisecon --- ### EC-WF-002 — Action on Wrong Workflow State + **Severity:** 🔴 Critical | **Type:** Security, Business Rule **Scenario A:** Reviewer พยายาม Approve เอกสารที่ถูก Cancel แล้ว **Scenario B:** Reviewer Approve เอกสารที่ Approve ไปแล้ว (Double-click) **Expected Behavior (A):** + - `GET /correspondences/:id` → status: CANCELLED → ปุ่ม Approve ไม่แสดง (UI) - ถ้าโจมตีตรงๆ ผ่าน API → 422 Unprocessable Entity (Invalid state transition) **Expected Behavior (B):** + - `workflow_state_transitions` ตรวจสอบ current_state + action ก่อน - ถ้า Action ไม่ Valid สำหรับ State ปัจจุบัน → 409 Conflict (Already processed) - Idempotency: ถ้า User กด Approve ซ้ำด้วย Action เดียวกัน → Return เดิม ไม่ Error @@ -164,27 +187,32 @@ Engineer A Approve พร้อมกับ Engineer B Approve ใน millisecon --- ### EC-WF-003 — Force Proceed on Final State + **Severity:** 🟠 High | **Type:** Business Rule, UX **Scenario:** Document Control กด "Force Proceed" บนเอกสารที่อยู่ใน APPROVED (Final State) แล้ว **Expected Behavior:** + - ถ้าไม่มี Next State ใน DSL → ปุ่ม Force Proceed ไม่แสดง (UI) - ถ้าเรียก API ตรงๆ → 422 (No next state available from current state) --- ### EC-WF-004 — Workflow Definition Changed During Execution + **Severity:** 🟡 Medium | **Type:** Business Rule, Data Integrity **Scenario:** Admin แก้ไข Workflow DSL ขณะที่มี Workflow Instance กำลังดำเนินการอยู่ **Expected Behavior:** + - Workflow Instance ที่กำลังเดินอยู่ **ใช้ DSL เวอร์ชันที่สร้าง Instance** (Snapshot at creation) - Instance ใหม่ที่สร้างหลังจากนั้นใช้ DSL เวอร์ชันใหม่ - ไม่มีการ Interrupt Instance ที่กำลังเดินอยู่ **Implementation Rule:** + ``` workflow_instances.workflow_definition_snapshot (JSON) — บันทึก DSL ณ เวลาสร้าง ไม่ Reference workflow_definitions.id โดยตรงสำหรับ Active Instances @@ -193,11 +221,13 @@ workflow_instances.workflow_definition_snapshot (JSON) — บันทึก DS --- ### EC-WF-005 — Deadline Passed — No Action Taken + **Severity:** 🟡 Medium | **Type:** Business Rule, UX **Scenario:** Deadline ของ Organization ผ่านไปแล้ว แต่ User ยังไม่ Approve **Expected Behavior:** + - Workflow **ไม่ Auto-advance** (ต้องการ Human Decision เสมอ) - Dashboard แสดง "Overdue" Badge (สีแดง) - Notification Reminder ส่งซ้ำตาม Schedule (ไม่ใช่ตลอดเวลา — Anti-Spam) @@ -208,11 +238,13 @@ workflow_instances.workflow_definition_snapshot (JSON) — บันทึก DS ## Module 3: File Storage Edge Cases ### EC-STOR-001 — File Upload During Network Interruption + **Severity:** 🟠 High | **Type:** UX, Data Integrity **Scenario:** User Upload ไฟล์ 50MB ผ่าน Wi-Fi แล้วเน็ตหลุดระหว่าง Upload **Expected Behavior:** + - Partial upload ไม่ถูก Save ใน Temp Storage - User เห็น Error: "การอัปโหลดล้มเหลว กรุณาลองใหม่" + ปุ่ม Retry - Draft ข้อมูล Form (ที่ไม่ใช่ไฟล์) ยังอยู่ใน LocalStorage (Auto-saved) @@ -221,11 +253,13 @@ workflow_instances.workflow_definition_snapshot (JSON) — บันทึก DS --- ### EC-STOR-002 — Virus Detected in Uploaded File + **Severity:** 🔴 Critical | **Type:** Security **Scenario:** User พยายาม Upload ไฟล์ที่ ClamAV ตรวจพบ Malware **Expected Behavior:** + - ClamAV Scan ใน Temp Storage → พบ → ลบไฟล์ออกจาก Temp ทันที - API ตอบ 422 Unprocessable Entity: `{ "error": "FILE_VIRUS_DETECTED", "filename": "..." }` - Audit Log บันทึก: `VIRUS_DETECTED` + filename + user_id + ip_address @@ -235,17 +269,20 @@ workflow_instances.workflow_definition_snapshot (JSON) — บันทึก DS --- ### EC-STOR-003 — File Type Mismatch (MIME Sniffing Attack) + **Severity:** 🔴 Critical | **Type:** Security **Scenario:** Attacker เปลี่ยน Extension ไฟล์ `malware.exe` → `document.pdf` แล้ว Upload **Expected Behavior:** + - Backend ตรวจ MIME Type จาก **File Content** (ไม่ใช่ Extension) - ถ้า MIME Type ไม่ตรงกับ Whitelist (PDF, DWG, ZIP, DOCX) → 400 Bad Request - ถ้า Extension กับ MIME Type ไม่ตรงกัน → 400 Bad Request: "File type mismatch" - Audit Log บันทึก Security Event **Whitelist:** + ``` PDF: application/pdf DWG: application/acad, image/vnd.dwg @@ -257,16 +294,19 @@ XLSX: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet --- ### EC-STOR-004 — Orphan File Cleanup (Document Cancelled Before Confirm) + **Severity:** 🟠 High | **Type:** Data Integrity, Storage **Scenario:** User Reserve Document Number → อัปโหลดไฟล์ไป Temp → Cancel Document → ออกจากหน้า **Expected Behavior:** + - Temp files ต้องถูกลบออกจาก Storage ภายใน 1 ชั่วโมง (Cleanup Cron) - ไม่มี Orphan Files ใน Temp Storage เกิน TTL - Permanent Storage ไม่มีไฟล์ที่ไม่มี Document Reference **Implementation Rule:** + ```typescript // Cron ทุกชั่วโมง // ลบ Temp files ที่ older than 1 hour และ ไม่ได้ถูก Confirm @@ -275,11 +315,13 @@ XLSX: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet --- ### EC-STOR-005 — Duplicate File Upload Detection + **Severity:** 🟡 Medium | **Type:** UX, Storage **Scenario:** User อัปโหลดไฟล์เดิมซ้ำสองครั้ง (ลืมว่าอัปโหลดแล้ว) **Expected Behavior:** + - **ไม่ Block** การ Upload ซ้ำ — เก็บเป็น 2 Attachment แยกกัน - แสดง Warning (ไม่ใช่ Error): "ไฟล์นี้อาจถูกอัปโหลดแล้ว — ชื่อเดียวกัน" - User สามารถลบ Duplicate ออกก่อน Submit @@ -289,11 +331,13 @@ XLSX: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet ## Module 4: RFA & Drawing Edge Cases ### EC-RFA-001 — 1 Shop Drawing Revision = Max 1 RFA Constraint + **Severity:** 🔴 Critical | **Type:** Business Rule, Data Integrity **Scenario:** Document Control พยายามสร้าง RFA ที่สอง สำหรับ Shop Drawing Revision เดิม **Expected Behavior:** + - ตรวจสอบ: `rfas WHERE shop_drawing_revision_id = X AND status NOT IN ('REJECTED', 'CANCELLED')` - ถ้ามี Active RFA อยู่แล้ว → 409 Conflict: "Shop Drawing Revision นี้มี RFA อยู่แล้ว" - UI: Disable ปุ่ม "สร้าง RFA" ถ้า Revision มี Active RFA แล้ว @@ -303,11 +347,13 @@ XLSX: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet --- ### EC-RFA-002 — RFA Revision While Previous Still Pending + **Severity:** 🟠 High | **Type:** Business Rule **Scenario:** RFA Rev.A ยัง Pending Review อยู่ แต่ Contractor พยายามสร้าง Rev.B **Expected Behavior:** + - ถ้า Rev.A ยังไม่มีคำตอบสุดท้าย (REJECTED/APPROVED/APPROVED_WITH_COMMENTS) → Block - 409 Conflict: "ต้องรอคำตอบของ Revision ก่อนหน้าก่อน" - ไม่อนุญาตให้มี 2 Active Revision พร้อมกัน @@ -315,11 +361,13 @@ XLSX: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet --- ### EC-RFA-003 — Shop Drawing Uploaded to Wrong Category + **Severity:** 🟡 Medium | **Type:** Business Rule, UX **Scenario:** User เลือก Discipline = "Structural" แต่ Upload Shop Drawing ที่เป็น Electrical **Expected Behavior (MVP):** + - ไม่มี Auto-detection (AI Classification เป็น Phase 3) - Validation: Discipline ต้องเลือก (ไม่มี Default) - เตือนผู้ใช้ให้ตรวจสอบก่อน Submit (Review Mode) @@ -328,11 +376,13 @@ XLSX: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet --- ### EC-RFA-004 — Transmittal Contains Mixed-Status RFAs + **Severity:** 🟠 High | **Type:** Business Rule **Scenario:** Transmittal ถูกสร้างโดยรวม RFA บางฉบับที่ยัง DRAFT และบางฉบับที่ READY **Expected Behavior:** + - Transmittal Submit ได้เฉพาะเมื่อ **ทุก RFA ใน Transmittal** อยู่ในสถานะ READY (ไม่ใช่ DRAFT) - ถ้ามี DRAFT อยู่ → 422: "RFA [เลข] ยังอยู่ใน Draft กรุณา Submit ก่อน" - UI: แสดง Status ของแต่ละ RFA ใน Transmittal ก่อน Submit @@ -342,12 +392,14 @@ XLSX: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet ## Module 5: Authentication & Session Edge Cases ### EC-AUTH-001 — Token Refresh Race Condition + **Severity:** 🔴 Critical | **Type:** Concurrency, Security **Scenario:** Browser Tab A และ Tab B ทำ API Call พร้อมกันด้วย Access Token ที่ Expired ทั้งสองตรวจพบ 401 และพยายาม Refresh Token พร้อมกัน **Expected Behavior:** + - ใช้ **Single Refresh Promise Pattern**: Tab แรกที่ Refresh สำเร็จ → Tab ที่สองใช้ Token ใหม่ (ไม่ Refresh ซ้อน) - ถ้า Tab ที่สอง Refresh ก็ได้ Token ใหม่เหมือนกัน → ถือว่า OK (Refresh Token ยังใช้ได้) - Refresh Token ถูก Rotate ทุกครั้งที่ใช้ (Refresh Token Rotation) @@ -357,11 +409,13 @@ XLSX: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet --- ### EC-AUTH-002 — Permission Changed While User is Logged In + **Severity:** 🔴 Critical | **Type:** Security, Business Rule **Scenario:** Admin เปลี่ยน Role ของ User จาก Document Control → Viewer ขณะที่ User กำลัง Login อยู่ **Expected Behavior:** + - Redis Permission Cache ของ User ถูกล้าง **ทันที** (ไม่รอ TTL) - Access Token เดิมยังใช้ได้จนหมดอายุ (15 นาที) — เป็นที่ยอมรับ - **Request ถัดไปหลัง Token Refresh** → Permission ใหม่มีผล @@ -370,31 +424,37 @@ XLSX: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet --- ### EC-AUTH-003 — Concurrent Login (Same Account, Multiple Devices) + **Severity:** 🟡 Medium | **Type:** Security, Business Rule **Scenario:** User Login จาก 2 Device พร้อมกัน (PC และ Tablet) **Expected Behavior (MVP):** + - อนุญาต (Session ทั้งสองทำงาน Independent) - แต่ละ Device มี Refresh Token แยกกัน - Logout จาก Device หนึ่ง → Revoke เฉพาะ Refresh Token ของ Device นั้น **Future Enhancement (Phase 2):** + - Option: "Logout จาก Device อื่นทั้งหมด" --- ### EC-AUTH-004 — Account Deactivated While Logged In + **Severity:** 🔴 Critical | **Type:** Security **Scenario:** Admin Deactivate User Account ขณะที่ User กำลัง Login อยู่ **Expected Behavior:** + - Redis: Blacklist User ID (ทุก Token ของ User นั้นถือว่า Invalid ทันที) - Request ถัดไปของ User → 401 Unauthorized: "Account has been deactivated" - User ถูก Redirect ไปหน้า Login พร้อม Message ชัดเจน **Implementation:** + ```typescript // ใน JWT Guard: ตรวจ Redis Blacklist ก่อน Validate Token const isBlacklisted = await redis.get(`user:blacklist:${userId}`); @@ -406,11 +466,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); ## Module 6: Permission & RBAC Edge Cases ### EC-PERM-001 — Direct Object Reference (IDOR Attack) + **Severity:** 🔴 Critical | **Type:** Security **Scenario:** User A รู้ ID ของเอกสาร User B (เช่น `/correspondences/12345`) แล้วเรียกตรงๆ **Expected Behavior:** + - CASL AbilityGuard ตรวจสอบทั้ง Action และ Resource Owner - ถ้าไม่มีสิทธิ์ → **403 Forbidden** (ไม่ใช่ 404 — เพราะ 404 บอกว่ามีอยู่แต่หาไม่เจอ) - **Exception:** ถ้าต้องการซ่อน Existence ของ Document → Return 404 @@ -419,11 +481,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); --- ### EC-PERM-002 — Super Admin Impersonation Prevention + **Severity:** 🔴 Critical | **Type:** Security **Scenario:** User พยายาม Forge JWT payload เพิ่ม role: 'SUPERADMIN' **Expected Behavior:** + - JWT ถูก Sign ด้วย Secret ที่ไม่เปิดเผย → Signature ไม่ตรง → 401 Invalid token - Role ไม่ถูก Read จาก Token โดยตรงสำหรับ Permission Check — ต้อง Verify จาก DB/Redis - JWT payload ใช้แค่ `user_id` → ดึง Permission จาก Redis Cache/DB @@ -431,11 +495,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); --- ### EC-PERM-003 — Organization Switch Mid-session + **Severity:** 🟡 Medium | **Type:** Business Rule, UX **Scenario (ถ้ามี):** User เป็นสมาชิกในหลาย Organization (กรณี Consultant ที่ทำงานหลายโครงการ) **Expected Behavior:** + - User เห็นเฉพาะ Data ขององค์กรที่ Login อยู่ (Active Context) - ถ้าต้องการดูอีก Org → ต้อง "Switch Organization" (Session Context เปลี่ยน) - ไม่มี Cross-org Data Leak แม้ User เป็นสมาชิกทั้งสอง Org @@ -445,11 +511,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); ## Module 7: Correspondence Edge Cases ### EC-CORR-001 — Cancel Correspondence with Downstream Circulation + **Severity:** 🔴 Critical | **Type:** Business Rule, Data Integrity **Scenario:** Correspondence ถูก Submit → ผู้รับสร้าง Circulation แล้ว → Originator ขอ Cancel **Expected Behavior:** + - ต้องแจ้งเตือน Admin ว่า "มี Circulation ที่เปิดอยู่ [X รายการ] สำหรับเอกสารนี้" - ต้องยืนยันก่อน Cancel: "การ Cancel จะส่งผลให้ Circulation ที่เกี่ยวข้องถูกปิดทั้งหมด" - เมื่อ Confirm → Correspondence = CANCELLED + Circulation ที่เกี่ยวข้อง = FORCE_CLOSED @@ -458,11 +526,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); --- ### EC-CORR-002 — Reply to Cancel Correspondence + **Severity:** 🟡 Medium | **Type:** Business Rule **Scenario:** Document Control พยายามสร้าง Correspondence เพื่อ Reply ต่อ Correspondence ที่ถูก Cancel **Expected Behavior:** + - Reply ทำได้ — Reference ถึง CANCELLED เอกสารได้ (เพื่อ acknowledge การยกเลิก) - UI แสดง Warning: "กำลัง Reply ต่อเอกสารที่ถูกยกเลิกแล้ว" - ไม่ Block การ Reply (เป็น Business Decision ไม่ใช่ Technical Constraint) @@ -470,11 +540,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); --- ### EC-CORR-003 — Correspondence to Self (Same Organization) + **Severity:** 🟡 Medium | **Type:** Business Rule **Scenario:** User พยายามสร้าง Correspondence ที่ Sender และ Receiver เป็นองค์กรเดียวกัน **Expected Behavior:** + - External Correspondence (Letter/RFI) → Block: "ไม่สามารถส่งหาตัวเองได้" - Internal Communication → ใช้ Circulation Sheet แทน (ไม่ใช่ Correspondence) - UI Validation + Backend Validation (Double Check) @@ -484,11 +556,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); ## Module 8: Circulation Edge Cases ### EC-CIRC-001 — Assignee Deactivated Before Completing Task + **Severity:** 🟠 High | **Type:** Business Rule, UX **Scenario:** User ถูก Deactivate หลังจากถูก Assign ใน Circulation แต่ก่อน Respond **Expected Behavior:** + - Circulation ยัง Active อยู่ — ไม่หยุดอัตโนมัติ - Document Control เห็น Warning: "Assignee [ชื่อ] ไม่ Active แล้ว" - Document Control สามารถ Re-assign ไปยัง User อื่นได้ @@ -497,11 +571,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); --- ### EC-CIRC-002 — Multi-Assignee: Partial Response + **Severity:** 🟡 Medium | **Type:** Business Rule, UX **Scenario:** Circulation มี Action Assignees 3 คน — 2 คน Respond แล้ว แต่ 1 คนยังไม่ Respond **Expected Behavior (MVP):** + - Document Control เห็นสถานะ "2/3 ตอบกลับแล้ว" - Document Control สามารถ Force Close ได้ (พร้อมระบุเหตุผล) - ถ้า Force Close → ทุก Partial Response ถูกบันทึก + หมายเหตุว่า Force Closed @@ -509,11 +585,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); --- ### EC-CIRC-003 — Circulation Deadline = Today (Edge of Day) + **Severity:** 🟡 Medium | **Type:** Business Rule, UX **Scenario:** Deadline ถูกกำหนด = "วันนี้" แต่ User ดูตอนบ่ายสอง **Expected Behavior:** + - ถ้า Deadline = วันที่ X → หมดเขตเมื่อ X เวลา 23:59:59 (ไม่ใช่ 00:00:00) - Reminder: ส่ง Notification เวลา 08:00 ของวัน Deadline - Overdue Badge ขึ้นเมื่อ `NOW() > deadline_date + 1 day` (วันถัดไป 00:00) @@ -523,11 +601,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); ## Module 9: Search & Elasticsearch Edge Cases ### EC-SRCH-001 — Search Index Lag (Eventual Consistency) + **Severity:** 🟡 Medium | **Type:** Data Consistency, UX **Scenario:** Document ถูก Submit แล้ว → User ค้นหาทันที แต่ไม่เจอ **Expected Behavior:** + - Index อาจ Lag 5–30 วินาที (BullMQ Async Job) - UI แสดง "เอกสารอาจใช้เวลาสักครู่ก่อนปรากฏในผลค้นหา" - **ไม่ถือว่า Bug** — เป็น By Design (Eventual Consistency) @@ -536,11 +616,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); --- ### EC-SRCH-002 — Permission-filtered Search Results + **Severity:** 🔴 Critical | **Type:** Security **Scenario:** Contractor A ค้นหา Keyword ที่มีใน Document ของ Contractor B **Expected Behavior:** + - Elasticsearch Index ต้องมี `organization_id` / `contract_id` Field - ทุก Search Query ต้อง Filter ด้วย `must: [{ term: { visible_to_org: userOrgId } }]` - Contractor A **ไม่เห็น** Document ของ Contractor B ในผลค้นหา @@ -549,11 +631,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); --- ### EC-SRCH-003 — Special Characters in Search Query + **Severity:** 🟡 Medium | **Type:** Security, UX **Scenario:** User ค้นหาด้วย `คคง. สค. - 2025` (มี `-`, `.`, ช่องว่าง) **Expected Behavior:** + - ไม่ Crash — Elasticsearch รองรับ Special Characters - Sanitize Query ก่อนส่ง (กัน Elasticsearch Injection) - ผล Search ยังคง Relevance สูง @@ -563,11 +647,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); ## Module 10: Notifications Edge Cases ### EC-NOTIF-001 — Notification Flood Prevention + **Severity:** 🟠 High | **Type:** UX, Anti-Spam **Scenario:** Workflow มีหลาย Step ที่เปลี่ยนเร็ว → ส่ง Notification ทุก State Change → User ได้รับ Email 10 ฉบับในนาทีเดียว **Expected Behavior:** + - **Notification Debounce/Batch:** รวม Notifications ภายใน 5 นาทีเป็น Summary Email เดียว - ถ้าเปลี่ยน State 5 ครั้งใน 5 นาที → Email เดียว: "เอกสาร X มี 5 การเปลี่ยนแปลง" - In-App Notifications ยังแสดงทุกรายการ (ไม่ Batch) @@ -575,11 +661,13 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); --- ### EC-NOTIF-002 — User Unsubscribed from EMAIL but still needs In-App + **Severity:** 🟡 Medium | **Type:** UX, Business Rule **Scenario:** User ปิด Email Notification แต่ยังต้องการ In-App Notification **Expected Behavior:** + - Notification Settings: แยก Toggle สำหรับ Email / LINE / In-App - Core Workflow Assignments (ที่ User ต้อง Action) → **ไม่สามารถ Disable** ทุก Channel ได้ - ต้องมี In-App อย่างน้อย 1 Channel สำหรับ Action Required Notifications @@ -588,34 +676,33 @@ if (isBlacklisted) throw new UnauthorizedException('Account deactivated'); ## 📊 Edge Case Summary by Module -| Module | Critical | High | Medium | Total | -|--------|----------|------|--------|-------| -| Document Numbering | 2 | 2 | 1 | 5 | -| Workflow Engine | 2 | 1 | 2 | 5 | -| File Storage | 2 | 2 | 1 | 5 | -| RFA & Drawing | 1 | 2 | 1 | 4 | -| Auth & Session | 3 | 0 | 1 | 4 | -| Permission & RBAC | 2 | 0 | 1 | 3 | -| Correspondence | 1 | 0 | 2 | 3 | -| Circulation | 0 | 1 | 2 | 3 | -| Search | 1 | 0 | 2 | 3 | -| Notifications | 0 | 1 | 1 | 2 | -| **รวม** | **14** | **9** | **14** | **37** | +| Module | Critical | High | Medium | Total | +| ------------------ | -------- | ----- | ------ | ------ | +| Document Numbering | 2 | 2 | 1 | 5 | +| Workflow Engine | 2 | 1 | 2 | 5 | +| File Storage | 2 | 2 | 1 | 5 | +| RFA & Drawing | 1 | 2 | 1 | 4 | +| Auth & Session | 3 | 0 | 1 | 4 | +| Permission & RBAC | 2 | 0 | 1 | 3 | +| Correspondence | 1 | 0 | 2 | 3 | +| Circulation | 0 | 1 | 2 | 3 | +| Search | 1 | 0 | 2 | 3 | +| Notifications | 0 | 1 | 1 | 2 | +| **รวม** | **14** | **9** | **14** | **37** | --- ## 🧪 Testing Strategy for Edge Cases ### สำหรับ Unit Tests (Backend) + ```typescript // ตัวอย่าง: EC-DN-001 — Concurrent Number Generation describe('DocumentNumberingService - Concurrency', () => { it('should generate unique numbers for concurrent requests', async () => { - const promises = Array.from({ length: 50 }, () => - service.reserve({ projectId: 1, typeId: 2, orgId: 3 }) - ); + const promises = Array.from({ length: 50 }, () => service.reserve({ projectId: 1, typeId: 2, orgId: 3 })); const results = await Promise.all(promises); - const numbers = results.map(r => r.documentNumber); + const numbers = results.map((r) => r.documentNumber); const unique = new Set(numbers); expect(unique.size).toBe(50); // ไม่มีซ้ำ }); @@ -623,11 +710,13 @@ describe('DocumentNumberingService - Concurrency', () => { ``` ### สำหรับ Integration Tests + - EC-DN-001: k6 Load Test Script (50 VUs, `/document-numbering/reserve`) - EC-AUTH-001: Cypress Multi-tab Token Refresh Test - EC-PERM-001: API Test Suite — Direct Object Reference สำหรับทุก Resource ### สำหรับ Manual UAT + - EC-WF-001: Test Parallel Approval ด้วย 2 Browser Session พร้อมกัน - EC-STOR-002: Upload EICAR Test File (ClamAV Test Virus) - EC-RFA-001: สร้าง RFA สำหรับ Revision เดิมที่มี Active RFA → Assert Block diff --git a/specs/01-requirements/01-07-ui-wireframes.md b/specs/01-requirements/01-07-ui-wireframes.md index 7a4ab46..d04a302 100644 --- a/specs/01-requirements/01-07-ui-wireframes.md +++ b/specs/01-requirements/01-07-ui-wireframes.md @@ -1,15 +1,18 @@ # 🖼️ UI/UX Wireframes & Screen Inventory — LCBP3-DMS v1.8.0 --- + title: 'UI/UX Screen Inventory, Navigation Map, and Wireframes' version: 1.0.0 status: DRAFT owner: Nattanin Peancharoen (Product Owner) last_updated: 2026-03-11 related: - - specs/01-Requirements/01-02-business-rules/01-02-03-ui-ux-rules.md - - specs/01-Requirements/01-04-user-stories.md - - specs/01-Requirements/01-05-acceptance-criteria.md + +- specs/01-Requirements/01-02-business-rules/01-02-03-ui-ux-rules.md +- specs/01-Requirements/01-04-user-stories.md +- specs/01-Requirements/01-05-acceptance-criteria.md + --- > [!NOTE] @@ -112,34 +115,34 @@ Mobile: Sidebar → Collapsible Hamburger Drawer (ตาม UI-Rule 5.11) ## 3. 📋 Screen Inventory -| Screen ID | Route | ชื่อหน้า | Primary Role | Priority | -|-----------|-------|---------|-------------|---------| -| SCR-001 | `/login` | Login | ทุก Role | 🔴 Must | -| SCR-002 | `/login/change-password` | Force Password Change | ทุก Role | 🔴 Must | -| SCR-003 | `/dashboard` | Dashboard | ทุก Role | 🔴 Must | -| SCR-004 | `/correspondences` | Correspondence List | Doc Control | 🔴 Must | -| SCR-005 | `/correspondences/new` | Create Correspondence | Doc Control | 🔴 Must | -| SCR-006 | `/correspondences/:id` | Correspondence Detail + Workflow | ทุก Role | 🔴 Must | -| SCR-007 | `/rfas` | RFA List | Doc Control | 🔴 Must | -| SCR-008 | `/rfas/new` | Create RFA | Doc Control | 🔴 Must | -| SCR-009 | `/rfas/:id` | RFA Detail + Workflow | ทุก Role | 🔴 Must | -| SCR-010 | `/transmittals` | Transmittal List | Doc Control | 🟠 Should | -| SCR-011 | `/transmittals/new` | Create Transmittal | Doc Control | 🟠 Should | -| SCR-012 | `/transmittals/:id` | Transmittal Detail | ทุก Role | 🟠 Should | -| SCR-013 | `/drawings/contract` | Contract Drawing List | Doc Control | 🟠 Should | -| SCR-014 | `/drawings/shop` | Shop Drawing List | Doc Control | 🟠 Should | -| SCR-015 | `/drawings/shop/:id` | Shop Drawing Detail | ทุก Role | 🟠 Should | -| SCR-016 | `/circulations` | Circulation List | Doc Control | 🟠 Should | -| SCR-017 | `/circulations/new` | Create Circulation | Doc Control | 🟠 Should | -| SCR-018 | `/circulations/:id` | Circulation Detail | ทุก Role | 🟠 Should | -| SCR-019 | `/search` | Search Results | ทุก Role | 🟠 Should | -| SCR-020 | `/notifications` | Notification Center | ทุก Role | 🟡 Could | -| SCR-021 | `/profile` | Profile & Settings | ทุก Role | 🟠 Should | -| SCR-022 | `/admin/users` | User Management | Org Admin+ | 🔴 Must | -| SCR-023 | `/admin/organizations` | Organization Management | Superadmin | 🔴 Must | -| SCR-024 | `/admin/projects` | Project & Contract Mgmt | Superadmin | 🔴 Must | -| SCR-025 | `/admin/doc-numbering` | Document Number Config | Superadmin | 🟠 Should | -| SCR-026 | `/admin/audit-logs` | Audit Log Viewer | Org Admin+ | 🟠 Should | +| Screen ID | Route | ชื่อหน้า | Primary Role | Priority | +| --------- | ------------------------ | -------------------------------- | ------------ | --------- | +| SCR-001 | `/login` | Login | ทุก Role | 🔴 Must | +| SCR-002 | `/login/change-password` | Force Password Change | ทุก Role | 🔴 Must | +| SCR-003 | `/dashboard` | Dashboard | ทุก Role | 🔴 Must | +| SCR-004 | `/correspondences` | Correspondence List | Doc Control | 🔴 Must | +| SCR-005 | `/correspondences/new` | Create Correspondence | Doc Control | 🔴 Must | +| SCR-006 | `/correspondences/:id` | Correspondence Detail + Workflow | ทุก Role | 🔴 Must | +| SCR-007 | `/rfas` | RFA List | Doc Control | 🔴 Must | +| SCR-008 | `/rfas/new` | Create RFA | Doc Control | 🔴 Must | +| SCR-009 | `/rfas/:id` | RFA Detail + Workflow | ทุก Role | 🔴 Must | +| SCR-010 | `/transmittals` | Transmittal List | Doc Control | 🟠 Should | +| SCR-011 | `/transmittals/new` | Create Transmittal | Doc Control | 🟠 Should | +| SCR-012 | `/transmittals/:id` | Transmittal Detail | ทุก Role | 🟠 Should | +| SCR-013 | `/drawings/contract` | Contract Drawing List | Doc Control | 🟠 Should | +| SCR-014 | `/drawings/shop` | Shop Drawing List | Doc Control | 🟠 Should | +| SCR-015 | `/drawings/shop/:id` | Shop Drawing Detail | ทุก Role | 🟠 Should | +| SCR-016 | `/circulations` | Circulation List | Doc Control | 🟠 Should | +| SCR-017 | `/circulations/new` | Create Circulation | Doc Control | 🟠 Should | +| SCR-018 | `/circulations/:id` | Circulation Detail | ทุก Role | 🟠 Should | +| SCR-019 | `/search` | Search Results | ทุก Role | 🟠 Should | +| SCR-020 | `/notifications` | Notification Center | ทุก Role | 🟡 Could | +| SCR-021 | `/profile` | Profile & Settings | ทุก Role | 🟠 Should | +| SCR-022 | `/admin/users` | User Management | Org Admin+ | 🔴 Must | +| SCR-023 | `/admin/organizations` | Organization Management | Superadmin | 🔴 Must | +| SCR-024 | `/admin/projects` | Project & Contract Mgmt | Superadmin | 🔴 Must | +| SCR-025 | `/admin/doc-numbering` | Document Number Config | Superadmin | 🟠 Should | +| SCR-026 | `/admin/audit-logs` | Audit Log Viewer | Org Admin+ | 🟠 Should | **รวม:** 26 หน้า (9 Must / 13 Should / 1 Could) @@ -536,58 +539,62 @@ User Edit Drawer (Slide in from right): ## 5. 🎨 Design System Reference ### Color Tokens + ```css /* Primary — ใช้กับ Action Buttons, Links */ ---primary: hsl(221, 83%, 53%); /* Blue-600 */ +--primary: hsl(221, 83%, 53%); /* Blue-600 */ --primary-hover: hsl(221, 83%, 45%); /* Status Colors */ ---status-draft: hsl(48, 96%, 53%); /* Yellow */ ---status-submitted: hsl(217, 91%, 60%); /* Blue */ ---status-review: hsl(24, 95%, 53%); /* Orange */ ---status-approved: hsl(142, 71%, 45%); /* Green */ ---status-rejected: hsl(0, 84%, 60%); /* Red */ ---status-cancelled: hsl(215, 14%, 55%); /* Gray */ ---status-overdue: hsl(0, 84%, 60%); /* Red (same as rejected) */ +--status-draft: hsl(48, 96%, 53%); /* Yellow */ +--status-submitted: hsl(217, 91%, 60%); /* Blue */ +--status-review: hsl(24, 95%, 53%); /* Orange */ +--status-approved: hsl(142, 71%, 45%); /* Green */ +--status-rejected: hsl(0, 84%, 60%); /* Red */ +--status-cancelled: hsl(215, 14%, 55%); /* Gray */ +--status-overdue: hsl(0, 84%, 60%); /* Red (same as rejected) */ /* Background */ ---bg-base: hsl(222, 47%, 11%); /* Dark Navy (dark mode base) */ ---bg-surface: hsl(222, 47%, 16%); /* Card surface */ ---bg-muted: hsl(215, 28%, 17%); /* Muted sections */ +--bg-base: hsl(222, 47%, 11%); /* Dark Navy (dark mode base) */ +--bg-surface: hsl(222, 47%, 16%); /* Card surface */ +--bg-muted: hsl(215, 28%, 17%); /* Muted sections */ ``` ### Typography + ```css font-family: 'Inter', 'Noto Sans Thai', sans-serif; /* Scale */ ---text-xs: 0.75rem; /* 12px — Badge, Caption */ ---text-sm: 0.875rem; /* 14px — Table cell, Label */ ---text-base:1rem; /* 16px — Body */ ---text-lg: 1.125rem; /* 18px — Subheading */ ---text-xl: 1.25rem; /* 20px — Page title */ ---text-2xl: 1.5rem; /* 24px — Dashboard KPI */ +--text-xs: 0.75rem; /* 12px — Badge, Caption */ +--text-sm: 0.875rem; /* 14px — Table cell, Label */ +--text-base: 1rem; /* 16px — Body */ +--text-lg: 1.125rem; /* 18px — Subheading */ +--text-xl: 1.25rem; /* 20px — Page title */ +--text-2xl: 1.5rem; /* 24px — Dashboard KPI */ ``` ### Component States -| Component | Default | Hover | Active | Disabled | Error | -|-----------|---------|-------|--------|----------|-------| -| Button Primary | bg-primary | bg-primary-hover | scale-95 | opacity-50 | — | -| Input | border-gray-300 | border-primary | border-primary ring | border-gray-200 | border-red-500 | -| Table Row | bg-surface | bg-muted | — | opacity-60 | bg-red-50 | -| Badge | per status color | — | — | — | — | + +| Component | Default | Hover | Active | Disabled | Error | +| -------------- | ---------------- | ---------------- | ------------------- | --------------- | -------------- | +| Button Primary | bg-primary | bg-primary-hover | scale-95 | opacity-50 | — | +| Input | border-gray-300 | border-primary | border-primary ring | border-gray-200 | border-red-500 | +| Table Row | bg-surface | bg-muted | — | opacity-60 | bg-red-50 | +| Badge | per status color | — | — | — | — | --- ## 6. 📱 Responsive Breakpoints -| Breakpoint | Width | Behavior | -|-----------|-------|---------| -| `sm` | < 640px | Mobile: Sidebar → Drawer, Table → Cards | -| `md` | 640-1024px | Tablet: Collapsed Sidebar | -| `lg` | > 1024px | Desktop: Full Sidebar | +| Breakpoint | Width | Behavior | +| ---------- | ---------- | --------------------------------------- | +| `sm` | < 640px | Mobile: Sidebar → Drawer, Table → Cards | +| `md` | 640-1024px | Tablet: Collapsed Sidebar | +| `lg` | > 1024px | Desktop: Full Sidebar | **Mobile-specific Rules (UI-Rule 5.11):** + - ตาราง → Card View อัตโนมัติ - Sidebar → Collapsible Hamburger Drawer - Action Panel → Bottom Sheet แทน Inline Panel @@ -597,6 +604,7 @@ font-family: 'Inter', 'Noto Sans Thai', sans-serif; ## 7. ⚡ Interaction Patterns ### Optimistic Updates (UI-Rule 5.10) + ``` User กด "Approve" → UI เปลี่ยนสถานะทันที (ไม่รอ API) ↓ @@ -606,12 +614,14 @@ Rollback UI → แสดง Toast Error: "เกิดข้อผิดพล ``` ### Auto-save Draft (UI-Rule 5.12) + ``` User พิมพ์ใน Form → debounce 2 วินาที → บันทึกลง localStorage ปิด Browser → เปิดใหม่ → แสดง Banner: "พบ Draft ที่บันทึกไว้ [กู้คืน] [ทิ้ง]" ``` ### File Upload Progress + ``` เลือกไฟล์ → แสดง Progress Bar → ClamAV Scan → ✅/❌ ``` diff --git a/specs/01-requirements/README.md b/specs/01-requirements/README.md index 6fc0c1c..9daf2bd 100644 --- a/specs/01-requirements/README.md +++ b/specs/01-requirements/README.md @@ -54,8 +54,6 @@ This directory contains the functional and non-functional requirements for the L - 📘 [Implementation Guide](../03-implementation/03-04-document-numbering.md) - NestJS, TypeORM, Redis code examples - 📗 [Operations Guide](../04-operations/04-08-document-numbering-operations.md) - Monitoring, troubleshooting, runbooks - - ### Cross-Cutting Concerns 4. [Access Control & RBAC](./01-01-business-rules/01-02-01-rbac-matrix.md) - 4-level hierarchical RBAC @@ -104,19 +102,19 @@ See [CHANGELOG.md](../../CHANGELOG.md) for detailed version history. ### By Feature Status -| Feature Area | Requirements Doc | Status | Implementation | Operations | -| ------------------------- | ---------------------------------------------------- | ---------- | ----------------------------------------------------------- | ------------------------------------------------------------------ | -| Correspondence Management | [03.2](./01-03.2-correspondence.md) | ✅ Complete | ✅ Complete | Available | -| RFA Management | [03.3](./01-03.3-rfa.md) | ✅ Complete | ✅ Complete | Available | -| Contract Drawing | [03.4](./01-03.4-contract-drawing.md) | ✅ Complete | ✅ Complete | Available | -| Shop Drawing | [03.5](./01-03.5-shop-drawing.md) | ✅ Complete | ✅ Complete | Available | -| Workflow Engine | [03.6](./01-03.6-unified-workflow.md) | ✅ Complete | ✅ Complete | Available | -| Transmittals | [03.7](./01-03.7-transmittals.md) | ✅ Complete | ✅ Complete | Available | -| Circulation Sheets | [03.8](./01-03.8-circulation-sheet.md) | ✅ Complete | ✅ Complete | Available | +| Feature Area | Requirements Doc | Status | Implementation | Operations | +| ------------------------- | ---------------------------------------------------- | ----------- | ------------------------------------------------------------ | ------------------------------------------------------------------- | +| Correspondence Management | [03.2](./01-03.2-correspondence.md) | ✅ Complete | ✅ Complete | Available | +| RFA Management | [03.3](./01-03.3-rfa.md) | ✅ Complete | ✅ Complete | Available | +| Contract Drawing | [03.4](./01-03.4-contract-drawing.md) | ✅ Complete | ✅ Complete | Available | +| Shop Drawing | [03.5](./01-03.5-shop-drawing.md) | ✅ Complete | ✅ Complete | Available | +| Workflow Engine | [03.6](./01-03.6-unified-workflow.md) | ✅ Complete | ✅ Complete | Available | +| Transmittals | [03.7](./01-03.7-transmittals.md) | ✅ Complete | ✅ Complete | Available | +| Circulation Sheets | [03.8](./01-03.8-circulation-sheet.md) | ✅ Complete | ✅ Complete | Available | | **Document Numbering** | [03.11](./01-03.11-document-numbering.md) | ✅ Complete | ✅ [Guide](../03-implementation/03-04-document-numbering.md) | ✅ [Guide](../04-operations/04-08-document-numbering-operations.md) | -| Access Control (RBAC) | [04](./01-02-business-rules/01-02-01-rbac-matrix.md) | ✅ Complete | ✅ Complete | Available | -| Search (Elasticsearch) | N/A | ✅ Complete | 🔄 95% | Available | -| Dashboard & Analytics | N/A | ✅ Complete | ✅ Complete | Available | +| Access Control (RBAC) | [04](./01-02-business-rules/01-02-01-rbac-matrix.md) | ✅ Complete | ✅ Complete | Available | +| Search (Elasticsearch) | N/A | ✅ Complete | 🔄 95% | Available | +| Dashboard & Analytics | N/A | ✅ Complete | ✅ Complete | Available | ### By Priority diff --git a/specs/02-architecture/02-01-system-context.md b/specs/02-architecture/02-01-system-context.md index 6ea398d..54385bc 100644 --- a/specs/02-architecture/02-01-system-context.md +++ b/specs/02-architecture/02-01-system-context.md @@ -8,16 +8,19 @@ status: first-draft owner: Nattanin Peancharoen last_updated: 2026-02-23 related: - - specs/01-requirements/01-objectives.md - - specs/03-implementation/03-01-fullftack-js-v1.7.0.md + +- specs/01-requirements/01-objectives.md +- specs/03-implementation/03-01-fullftack-js-v1.7.0.md --- ## 1. 📋 ภาพรวมระบบ (System Overview) + ระบบ LCBP3-DMS (Laem Chabang Port Phase 3 - Document Management System) ถูกออกแบบด้วยสถาปัตยกรรมแบบ **Headless/API-First Architecture** โดยทำงานแบบ **On-Premise 100%** บนเครื่องเซอร์ฟเวอร์ QNAP และ ASUSTOR ระบบทั้งหมดทำงานอยู่ภายใต้สภาวะแวดล้อมแบบ **Container Isolation** (ผ่าน Container Station) เพื่อความปลอดภัย, ง่ายต่อการจัดการ, และไม่ยึดติดกับ Hardware (Hardware Agnostic) ### 1.1 Architecture Principles + 1. **Data Integrity First:** ความถูกต้องของข้อมูลต้องมาก่อนทุกอย่าง 2. **Security by Design & Container Isolation:** รักษาความปลอดภัยที่ทุกชั้น และแยกส่วนการทำงานของแต่ละระบบอย่างชัดเจน (Network Segmentation & Containerization) 3. **On-Premise First:** ข้อมูลและระบบงานทั้งหมดต้องอยู่ภายในเครือข่ายของโครงการเท่านั้น @@ -27,6 +30,7 @@ related: ## 2. 🏢 มาตรฐานการติดตั้ง On-Premise (QNAP/ASUSTOR Installation Standards) ### 2.1 Hardware Infrastructure + - **Primary Server (QNAP TS-473A):** - IP: 192.168.10.8 (VLAN 10) - Role: Primary NAS for DMS, Container Host @@ -37,7 +41,9 @@ related: - **Network Interface:** NAS ทั้งสองตัวใช้ LACP bonding แบบ IEEE 802.3ad เพื่อเพิ่ม bandwidth และ redundancy ### 2.2 Container Isolation & Environment + อ้างอิงข้อจำกัดและมาตรฐานของระบบ Container Station บน QNAP: + - **Containerization Engine:** Docker & Docker Compose - **Network Isolation:** ทุกคอนเทนเนอร์ (Frontend, Backend, Database, Redis, Search) จะต้องเชื่อมต่อกันผ่าน Internal Docker Network ชื่อ `lcbp3` เท่านั้น ห้าม Expose Port ออกสู่ภายนอกโดยไม่จำเป็น - **Reverse Proxy:** ใช้ Nginx Proxy Manager (`npm.np-dms.work`) เป็น Gateway (Load Balancer & SSL Termination) รับ Traffic เพียงจุดเดียวบน Port 80/443 และ Map เข้าสู่ Internal Docker Network @@ -51,6 +57,7 @@ related: ระบบจัดแบ่งเครือข่ายออกเป็น VLANs ต่างๆ เพื่อการควบคุมการเข้าถึงตามหลักการ Zero Trust ย่อยๆ โดยใช้อุปกรณ์เครือข่าย (ER7206 Router & SG2428P Core Switch) ในการบังคับใช้ ACL: ### 3.1 VLAN Configuration + | VLAN ID | Name | Purpose | Subnet | Gateway | Notes | | ------- | ------ | ------------------ | --------------- | ------------ | ----------------------------------------- | | 10 | SERVER | Server & Storage | 192.168.10.0/24 | 192.168.10.1 | Servers (QNAP, ASUSTOR). Static IPs ONLY. | @@ -62,6 +69,7 @@ related: | 70 | GUEST | Guest Wi-Fi | 192.168.70.0/24 | 192.168.70.1 | Isolated Internet Access only. | ### 3.2 Network ACL & Isolation Rules + - **SERVER Isolation:** `VLAN 30 (USER)` สามารถเข้าถึง `VLAN 10 (SERVER)` ได้เฉพาะพอร์ตที่จำเป็น (HTTP/HTTPS/SSH) - **MGMT Restriction:** ไม่อนุญาตให้ `VLAN 30 (USER)` เข้าถึง `VLAN 20 (MGMT)` โดยเด็ดขาด - **Device Isolation:** `CCTV`, `VOICE`, และ `GUEST` แยกขาดออกจากวงอื่นๆ (Deny-All to Internal) @@ -83,6 +91,7 @@ related: | **Search** | - | - | Elasticsearch | Full-text Indexing | ## 5. 📊 Data Flow & Interactions + 1. **User Request:** ผู้ใช้งานส่ง Request ไปที่โดเมนผ่าน HTTP/HTTPS 2. **Reverse Proxy:** Nginx Proxy Manager รับ Request, ตรวจสอบ SSL, และ Forward ไปให้ Frontend หรือ Backend ในวง Docker Network 3. **API Processing:** Backend รัน Business Logic, ประมวลผล Authentication (JWT) และ Permissions (RBAC via Redis Cache) @@ -90,6 +99,7 @@ related: 5. **Storage Process:** ไฟล์ถูกคัดกรองผ่าน ClamAV (ถ้ามี) และเก็บลง Storage `/share/dms-data` บน QNAP แบบ Two-Phase Storage (Temp -> Permanent) เพื่อป้องกัน Orphan Files ## 6. 💾 Backup & Disaster Recovery (DR) + - **Database Backup:** ทำ Automated Backup รายวันด้วย QNAP HBS 3 หรือ mysqldump - **File Backup:** ทำ Snapshot หรือ rsync จาก `/share/dms-data` บนเครื่องหลัก (QNAP) ไปยังเครื่องสำรอง (ASUSTOR) อย่างสม่ำเสมอ - **Recovery Standard:** หาก NAS พัง สามารถ Restore Config ย้ายข้อมูล และรัน `docker-compose up` ขึ้นใหม่บนเครื่อง Backup ได้ทันที เนื่องจาก Architecture ออกแบบแบบ Stateless สำหรับตัวแอพพลิเคชั่น และ Data แยกลง Volume Storage ชัดเจน diff --git a/specs/02-architecture/02-02-software-architecture.md b/specs/02-architecture/02-02-software-architecture.md index e577207..1e6c69c 100644 --- a/specs/02-architecture/02-02-software-architecture.md +++ b/specs/02-architecture/02-02-software-architecture.md @@ -8,13 +8,15 @@ status: first-draft owner: Nattanin Peancharoen last_updated: 2026-02-23 related: - - specs/02-Architecture/00-01-system-context.md + +- specs/02-Architecture/00-01-system-context.md --- ## 1. 🧱 Backend Module Architecture (NestJS) ### 1.1 Modular Design + ```mermaid graph TB subgraph "Core Modules" @@ -52,21 +54,27 @@ graph TB ### 1.2 Key Architectural Patterns #### Unified Workflow Engine (DSL-Based) + ระบบการเดินเอกสาร (Correspondence, RFA, Circulation) ใช้ Engine กลางเดียวกัน ผ่าน **Workflow DSL (JSON Configuration)** + - **Separation of Concerns:** Modules เก็บเฉพาะข้อมูล (Data) ส่วน Flow/State ถูกจัดการโดย Engine - **Versioning:** อาศัย Workflow Definition Version ป้องกันความขัดแย้งของ State เมื่อมีการแก้ไข Flow #### Double-Locking Mechanism (Auto Numbering) + เพื่อป้องกัน Race Condition ในการขอเลขเอกสารพร้อมกัน: + - **Layer 1:** Redis Distributed Lock (ล็อคการเข้าถึงในระดับ Server/Network) - **Layer 2:** Optimistic Database Lock ผ่าน `@VersionColumn()` (ป้องกันระดับ Data Record) #### Idempotency + ทุก API ที่แก้ไขสถานะจะต้องส่ง `Idempotency-Key` ป้องกันผู้ใช้กดยืนยันซ้ำสองรอบ ## 2. 📊 Data Flow & Processes ### 2.1 Main Request Flow + ```mermaid sequenceDiagram participant Client as Client @@ -90,7 +98,9 @@ sequenceDiagram ``` ### 2.2 File Upload Flow (Two-Phase Storage) + ใช้แบบ **Two-Phase** เพื่อลดความเสี่ยงเกิดไฟล์ขยะ (Orphan Files): + 1. **[Phase 1]:** Client อัปโหลดไฟล์ -> ตรวจ Virus -> วางไว้ที่โฟลเดอร์ `temp/` -> ส่ง `temp_id` กลับให้ Client 2. **[Phase 2]:** Client สั่ง Create Document (แนบ `temp_id`) -> Backend บันทึกฐานข้อมูล -> ย้ายไฟล์จาก `temp/` ไปที่ `permanent/` -> สร้างตาราง Attachment -> Commit Transaction 3. **[Cleanup Job]:** ครอนจ็อบตามลบไฟล์ที่ค้างอยู่ใน `temp/` เกิน 24 ชั่วโมง @@ -98,12 +108,14 @@ sequenceDiagram ## 3. 🛡️ Security Architecture ### 3.1 Rate Limiting (Redis-backed) + - Anonymous: 100 req/hour - File Upload: 50 req/hour - Document Control: 2000 req/hour - Admin: 5000 req/hour ### 3.2 Authorization checking flow (CASL) + 1. ดึง JWT Token ตรวจสอบความถูกต้อง 2. โหลด User Permissions จาก Redis (`user:{user_id}:permissions`) 3. ตรวจสอบเงื่อนไขตาม Context: @@ -114,6 +126,7 @@ sequenceDiagram 4. พิจารณาอนุญาตหากระดับใดระดับหนึ่งอนุญาต (Most Permissive approach) ### 3.3 OWASP Top 10 Protections implemented + - **SQL Injection:** Parameterized Queries via TypeORM - **XSS/CSRF:** Input Sanitization, CSRF Tokens - **Insecure File Upload:** Magic Number Validation (ไม่ใช่แค่ extension), ไวรัสสแกน, สิทธิเข้าถึงไฟล์ถูกห่อหุ้มด้วย Authorization endpoint เสมอ ไม่ปล่อย public link diff --git a/specs/02-architecture/02-03-network-design.md b/specs/02-architecture/02-03-network-design.md index 7814fd0..5272bf2 100644 --- a/specs/02-architecture/02-03-network-design.md +++ b/specs/02-architecture/02-03-network-design.md @@ -8,8 +8,9 @@ status: first-draft owner: Nattanin Peancharoen last_updated: 2026-02-23 related: - - specs/02-Architecture/00-01-system-context.md - - specs/02-Architecture/02-03-software-architecture.md + +- specs/02-Architecture/00-01-system-context.md +- specs/02-Architecture/02-03-software-architecture.md --- @@ -67,8 +68,10 @@ flowchart TB ``` ### 2.1 กฎเหล็ก: การเข้าถึงระบบฐานข้อมูล (Database Access Restriction) + > [!CAUTION] > **MariaDB และ Redis ตั้งอยู่ใน DATA ZONE ภายใต้ Docker Network ภายในชื่อ `lcbp3` เท่านั้น** + - **ห้าม Expose Port ออกสู่ Host โดยตรง:** `mariadb:3306` และ `redis:6379` จะต้องไม่ถูกเปิดสิทธิออกสู่ภายนอก Container Station - **การเข้าถึงจากระบบอื่น:** เฉพาะ Service ใน **APPLICATION ZONE** (เช่น NestJS Backend) และ Service อื่นบน Network `lcbp3` เท่านั้นที่จะสามารถเรียกใช้งาน Database ได้ - **การจัดการโดย Admin:** หากผู้ดูแลระบบต้องการเข้าไปจัดการฐานข้อมูล จะต้องใช้งานผ่าน **phpMyAdmin** (`pma.np-dms.work`) ซึ่งถูกจำกัดสิทธิเข้าถึงผ่าน Nginx Proxy Manager อีกชั้น หรือผ่าน SSH Tunnel เข้าสู่เซิร์ฟเวอร์เท่านั้น @@ -111,6 +114,7 @@ graph TB ``` ### 3.1 Switch Profiles & Interfaces + - **01_CORE_TRUNK:** Router & switch uplinks (Native: 20, Tagged: All) - **02_MGMT_ONLY:** Management only (Native: 20, Untagged: 20) - **03_SERVER_ACCESS:** QNAP / ASUSTOR (Native: 10, Untagged: 10) @@ -120,6 +124,7 @@ graph TB - **07_VOICE_ACCESS:** IP Phones (Native: 30, Tagged: 50, Untagged: 30) ### 3.2 NAS NIC Bonding Configuration + | Device | Bonding Mode | Member Ports | VLAN Mode | Tagged VLAN | IP Address | Gateway | Notes | | ------- | ------------------- | ------------ | --------- | ----------- | --------------- | ------------ | ---------------------- | | QNAP | IEEE 802.3ad (LACP) | Adapter 1, 2 | Untagged | 10 (SERVER) | 192.168.10.8/24 | 192.168.10.1 | Primary NAS for DMS | @@ -130,7 +135,9 @@ graph TB กฎของ Firewall จะถูกกำหนดบน Omada Controller และอุปกรณ์ Gateway (ER7206) ตามหลักการอนุญาตแค่สิ่งที่ต้องการ (Default Deny) ### 4.1 IP Groups & Port Groups (อ้างอิงบ่อย) + **IP Groups:** + - `Server`: 192.168.10.8, 192.168.10.9, 192.168.10.111 - `Omada-Controller`: 192.168.20.250 - `DHCP-Gateways`: 192.168.30.1, 192.168.70.1 @@ -139,50 +146,56 @@ graph TB - `Blacklist`: (เพิ่ม IP ประสงค์ร้าย) **Port Groups:** + - `Web`: TCP 443, 8443, 80, 81, 2222 - `Omada-Auth`: TCP 443, 8043, 8088, 8843, 29810-29814 - `VoIP`: UDP 5060, 5061, 10000-20000 (SIP + RTP) - `DHCP`: UDP 67, 68 ### 4.2 Switch ACL (สำหรับ Omada OC200) + > ⚠️ **ลำดับความสำคัญ (Priority Level):** (1) Allow rules (DHCP, Auth) -> (2) Isolate/Deny rules -> (3) Allow specific services -> (4) Default Deny -| ลำดับ | Name | Policy | Source | Destination | Ports | -| :--- | :------------------------ | :----- | :---------------- | :---------------------------- | :---------------------------------------- | -| 1 | 01 Allow-User-DHCP | Allow | Network → VLAN 30 | IP → 192.168.30.1 | Port Group → DHCP | -| 2 | 02 Allow-Guest-DHCP | Allow | Network → VLAN 70 | IP → 192.168.70.1 | Port Group → DHCP | -| 3 | 03 Allow-WiFi-Auth | Allow | Network → VLAN 30 | IP Group → Omada-Controller | Port Group → Omada-Auth | -| 4 | 04 Allow-Guest-WiFi-Auth | Allow | Network → VLAN 70 | IP Group → Omada-Controller | Port Group → Omada-Auth | -| 5 | 05 Isolate-Guests | Deny | Network → VLAN 70 | Network → VLAN 10, 20, 30, 60 | All | -| 6 | 06 Isolate-Servers | Deny | Network → VLAN 10 | Network → VLAN 30 (USER) | All | -| 7 | 07 Block-User-to-Mgmt | Deny | Network → VLAN 30 | Network → VLAN 20 (MGMT) | All | -| 8 | 08 Allow-User-to-Services | Allow | Network → VLAN 30 | IP → QNAP (192.168.10.8) | Port Group → Web (443,8443, 80, 81, 2222) | -| 9 | 09 Allow-Voice-to-User | Allow | Network → VLAN 50 | Network → VLAN 30,50 | All | -| 10 | 10 Allow-MGMT-to-All | Allow | Network → VLAN 20 | Any | All | -| 11 | 11 Allow-Server-Internal | Allow | IP Group : Server | IP Group : Server | All | -| 12 | 12 Allow-Server → CCTV | Allow | IP Group : Server | Network → VLAN 40 (CCTV) | All | -| 13 | 100 (Default) | Deny | Any | Any | All | +| ลำดับ | Name | Policy | Source | Destination | Ports | +| :---- | :------------------------ | :----- | :---------------- | :---------------------------- | :---------------------------------------- | +| 1 | 01 Allow-User-DHCP | Allow | Network → VLAN 30 | IP → 192.168.30.1 | Port Group → DHCP | +| 2 | 02 Allow-Guest-DHCP | Allow | Network → VLAN 70 | IP → 192.168.70.1 | Port Group → DHCP | +| 3 | 03 Allow-WiFi-Auth | Allow | Network → VLAN 30 | IP Group → Omada-Controller | Port Group → Omada-Auth | +| 4 | 04 Allow-Guest-WiFi-Auth | Allow | Network → VLAN 70 | IP Group → Omada-Controller | Port Group → Omada-Auth | +| 5 | 05 Isolate-Guests | Deny | Network → VLAN 70 | Network → VLAN 10, 20, 30, 60 | All | +| 6 | 06 Isolate-Servers | Deny | Network → VLAN 10 | Network → VLAN 30 (USER) | All | +| 7 | 07 Block-User-to-Mgmt | Deny | Network → VLAN 30 | Network → VLAN 20 (MGMT) | All | +| 8 | 08 Allow-User-to-Services | Allow | Network → VLAN 30 | IP → QNAP (192.168.10.8) | Port Group → Web (443,8443, 80, 81, 2222) | +| 9 | 09 Allow-Voice-to-User | Allow | Network → VLAN 50 | Network → VLAN 30,50 | All | +| 10 | 10 Allow-MGMT-to-All | Allow | Network → VLAN 20 | Any | All | +| 11 | 11 Allow-Server-Internal | Allow | IP Group : Server | IP Group : Server | All | +| 12 | 12 Allow-Server → CCTV | Allow | IP Group : Server | Network → VLAN 40 (CCTV) | All | +| 13 | 100 (Default) | Deny | Any | Any | All | ### 4.3 Gateway ACL (สำหรับ ER7206) -| ลำดับ | Name | Policy | Direction | PROTOCOLS | Source | Destination | -| :--- | :---------------------- | :----- | :-------- | :-------- | :------------------- | :--------------------------- | -| 1 | 01 Blacklist | Deny | [WAN2] IN | All | IP Group:Blacklist | IP Group:Internal | -| 2 | 02 Geo | Permit | [WAN2] IN | All | Location Group:Allow | IP Group:Internal | -| 3 | 03 Allow-Voice-Internet | Permit | LAN->WAN | UDP | Network → VLAN 50 | Any | -| 4 | 04 Internal → Internet | Permit | LAN->WAN | All | IP Group:Internal | Domain Group:DomainGroup_Any | + +| ลำดับ | Name | Policy | Direction | PROTOCOLS | Source | Destination | +| :---- | :---------------------- | :----- | :-------- | :-------- | :------------------- | :--------------------------- | +| 1 | 01 Blacklist | Deny | [WAN2] IN | All | IP Group:Blacklist | IP Group:Internal | +| 2 | 02 Geo | Permit | [WAN2] IN | All | Location Group:Allow | IP Group:Internal | +| 3 | 03 Allow-Voice-Internet | Permit | LAN->WAN | UDP | Network → VLAN 50 | Any | +| 4 | 04 Internal → Internet | Permit | LAN->WAN | All | IP Group:Internal | Domain Group:DomainGroup_Any | ### 4.4 Port Forwarding + Traffic สาธารณะ (WAN) จะถูกเชื่อมต่อไปยัง Nginx Proxy Manager เพียงจุดเดียว + - **Allow-NPM-HTTPS:** External Port 443 -> QNAP (192.168.10.8) Port 443 (TCP) - **Allow-NPM-HTTP (สำหรับ Let's Encrypt):** External Port 80 -> QNAP (192.168.10.8) Port 80 (TCP) ## 5. 📡 EAP ACL (Wireless Data Flow Rules) ตั้งค่าสำหรับ Access Points ให้ป้องกันการ Broadcast ลดทอนกันเอง หรือรบกวนโซนอื่นๆ -* **SSID: PSLCBP3 (Staff WiFi) - VLAN 30** + +- **SSID: PSLCBP3 (Staff WiFi) - VLAN 30** - อนุญาต DNS, 192.168.10.0/24 (Servers), Printer, Internet - **บล็อค** การเข้าสู่ 192.168.20.0/24 (MGMT), 192.168.40.0/24 (CCTV), และ **Client Isolation (Client-2-Client Deny)** -* **SSID: GUEST (Guest WiFi) - VLAN 70** +- **SSID: GUEST (Guest WiFi) - VLAN 70** - อนุญาต DNS, Internet (HTTP/HTTPS) - **บล็อคเครือข่ายส่วนตัวทั้งหมด (RFC1918):** 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 และสั่ง **Client Isolation** diff --git a/specs/02-architecture/02-04-api-design.md b/specs/02-architecture/02-04-api-design.md index 457d34e..4e9ed9b 100644 --- a/specs/02-architecture/02-04-api-design.md +++ b/specs/02-architecture/02-04-api-design.md @@ -8,9 +8,10 @@ **owner:** Nattanin Peancharoen **last_updated:** 2026-02-23 **related:** - - specs/02-Architecture/00-01-system-context.md - - specs/02-Architecture/02-03-software-architecture.md - - specs/03-Implementation/03-01-fullstack-js-v1.7.0.md + +- specs/02-Architecture/00-01-system-context.md +- specs/02-Architecture/02-03-software-architecture.md +- specs/03-Implementation/03-01-fullstack-js-v1.7.0.md --- @@ -21,17 +22,20 @@ ## 2. 🎯 หลักการออกแบบ API (API Design Principles) ### 2.1 API-First Approach + - **ออกแบบ API ก่อนการ Implement:** ทำการออกแบบ API Endpoint และ Data Contract ให้ชัดเจนก่อนเริ่มเขียนโค้ด - **Documentation-Driven:** ใช้ OpenAPI/Swagger เป็นเอกสารอ้างอิงหลัก - **Contract Testing:** ทดสอบ API ตาม Contract ที่กำหนดไว้ ### 2.2 RESTful Principles + - ใช้ HTTP Methods อย่างถูกต้อง: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` - ใช้ HTTP Status Codes ที่เหมาะสม - Resource-Based URL Design - Stateless Communication ### 2.3 Consistency & Predictability + - **Naming Conventions:** ใช้ `kebab-case` สำหรับ URL paths - **Property Naming:** ใช้ `camelCase` สำหรับ JSON properties และ query parameters (สอดคล้องกับ TypeScript/JavaScript conventions) - **Database Columns:** Database ใช้ `snake_case` (mapped via TypeORM decorators) @@ -40,6 +44,7 @@ ## 3. 🔐 Authentication & Authorization ### 3.1 Authentication + - **JWT-Based Authentication:** ใช้ JSON Web Token สำหรับการยืนยันตัวตน - **Token Management:** - Access Token Expiration: 8 ชั่วโมง @@ -48,18 +53,22 @@ - Token Revocation: บันทึก Revoked Tokens จนกว่าจะหมดอายุ **Endpoints คอร์:** + ```typescript -POST /api/v1/auth/login -POST /api/v1/auth/logout -POST /api/v1/auth/refresh -POST /api/v1/auth/change-password +POST / api / v1 / auth / login; +POST / api / v1 / auth / logout; +POST / api / v1 / auth / refresh; +POST / api / v1 / auth / change - password; ``` ### 3.2 Authorization (RBAC) (CASL) + ใช้ระบบ 4-Level Permission Hierarchy (Global, Organization, Project, Contract) + - **Permission Checking:** ใช้ Decorator `@RequirePermission('resource.action')` **Example:** + ```typescript @RequirePermission('correspondence.create') @Post('correspondences') @@ -69,29 +78,34 @@ async createCorrespondence(@Body() dto: CreateCorrespondenceDto) { ``` ### 3.3 Token Payload Optimization + - JWT Payload เก็บเฉพาะ `userId` และ `scope` ปัจจุบัน - **Permissions Caching:** เก็บ Permission List ใน Redis และดึงมาตรวจสอบเมื่อมี Request ## 4. 📡 API Conventions ### 4.1 Base URL Structure + ``` https://backend.np-dms.work/api/v1/{resource} ``` ### 4.2 HTTP Methods & Usage -| Method | Usage | Idempotent | Example | -| :------- | :--------------------------- | :--------- | :----------------------------------- | -| `GET` | ดึงข้อมูล (Read) | ✅ Yes | `GET /api/v1/correspondences` | -| `POST` | สร้างข้อมูลใหม่ (Create) | ❌ No\* | `POST /api/v1/correspondences` | -| `PUT` | อัปเดตทั้งหมด (Full Update) | ✅ Yes | `PUT /api/v1/correspondences/:id` | -| `PATCH` | อัปเดตบางส่วน (Partial Update) | ✅ Yes | `PATCH /api/v1/correspondences/:id` | -| `DELETE` | ลบข้อมูล (Soft Delete) | ✅ Yes | `DELETE /api/v1/correspondences/:id` | -* **Note:** `POST` เป็น Idempotent ได้เมื่อใช้ `Idempotency-Key` Header +| Method | Usage | Idempotent | Example | +| :------- | :----------------------------- | :--------- | :----------------------------------- | +| `GET` | ดึงข้อมูล (Read) | ✅ Yes | `GET /api/v1/correspondences` | +| `POST` | สร้างข้อมูลใหม่ (Create) | ❌ No\* | `POST /api/v1/correspondences` | +| `PUT` | อัปเดตทั้งหมด (Full Update) | ✅ Yes | `PUT /api/v1/correspondences/:id` | +| `PATCH` | อัปเดตบางส่วน (Partial Update) | ✅ Yes | `PATCH /api/v1/correspondences/:id` | +| `DELETE` | ลบข้อมูล (Soft Delete) | ✅ Yes | `DELETE /api/v1/correspondences/:id` | + +- **Note:** `POST` เป็น Idempotent ได้เมื่อใช้ `Idempotency-Key` Header ### 4.3 Request Format + **Request Headers:** + ```http Content-Type: application/json Authorization: Bearer @@ -99,6 +113,7 @@ Idempotency-Key: # สำหรับ POST/PUT/DELETE ``` ### 4.4 HTTP Status Codes + | Status | Use Case | | ------------------------- | ------------------------------------------- | | 200 OK | Successful GET, PUT, PATCH | @@ -120,6 +135,7 @@ Idempotency-Key: # สำหรับ POST/PUT/DELETE ### 5.1 Success Response **Single Resource:** + ```typescript { "data": { @@ -135,6 +151,7 @@ Idempotency-Key: # สำหรับ POST/PUT/DELETE ``` **Collection (Pagination):** + ```typescript { "data": [ @@ -154,6 +171,7 @@ Idempotency-Key: # สำหรับ POST/PUT/DELETE ``` ### 5.2 Error Response Format + ```typescript { "error": { @@ -176,6 +194,7 @@ Idempotency-Key: # สำหรับ POST/PUT/DELETE ## 6. 🛠️ NestJS Implementation Details ### 6.1 Global Exception Filter + คลาสจัดการ Error หลักที่จะจับและดัดแปลง Error ส่งคืน Client อย่างสม่ำเสมอ ```typescript @@ -225,6 +244,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { ``` ### 6.2 Custom Business Exception + สำหรับจัดการข้อผิดพลาดเชิงความสัมพันธ์ หรือเงื่อนไขธุรกิจ เช่น State Conflict. ```typescript @@ -242,13 +262,11 @@ export class BusinessException extends HttpException { } // Usage Example: -throw new BusinessException( - 'Cannot approve correspondence in current status', - 'INVALID_WORKFLOW_TRANSITION' -); +throw new BusinessException('Cannot approve correspondence in current status', 'INVALID_WORKFLOW_TRANSITION'); ``` ### 6.3 Validation Pipe Configuration + บังคับ Validation Pipe ก่อนส่งพารามิเตอร์ให้กับ Controller ```typescript @@ -297,6 +315,7 @@ app.useGlobalPipes( | Authentication | 10 requests/minute | IP | ### 7.2 File Upload Security + - **Virus Scanning:** ใช้ ClamAV scan ทุกไฟล์ - **File Type Validation:** White-list (PDF, DWG, DOCX, XLSX, ZIP) - **File Size Limit:** 50MB per file @@ -314,21 +333,25 @@ app.useGlobalPipes( ## 9. 📈 Optimization & Additional Guidelines ### 9.1 Caching Strategy + - Master Data: 1 hour - User Sessions: 30 minutes - Search Results: 15 minutes - File Metadata: 1 hour ### 9.2 API Versioning + - **URL-Based Versioning:** `/api/v1/...`, `/api/v2/...` - **Backward Compatibility:** รองรับ API เวอร์ชันเก่าอย่างน้อย 1 เวอร์ชัน - ใช้ Deprecation Headers เมื่อมีการยกเลิก Endpoints ### 9.3 Documentation + - **Swagger/OpenAPI:** Auto-generated จาก NestJS Decorators - **URL:** `https://backend.np-dms.work/api/docs` ## 🎯 สรุป Best Practices + 1. **ใช้ DTOs สำหรับ Validation ทุก Request** 2. **ส่ง Idempotency-Key สำหรับ Critical Operations** 3. **ใช้ Proper HTTP Status Codes** diff --git a/specs/02-architecture/README.md b/specs/02-architecture/README.md index 8d18800..45eba23 100644 --- a/specs/02-architecture/README.md +++ b/specs/02-architecture/README.md @@ -194,17 +194,17 @@ Layer 6: File Security (Virus Scanning, Access Control) ### Frontend Stack -| Component | Technology | Purpose | -| -------------------- | -------------------------------- | ---------------------------- | -| **Framework** | Next.js 14+ (App Router) | React Framework with SSR | -| **Language** | TypeScript (ESM) | Type-safe JavaScript | -| **Styling** | Tailwind CSS + PostCSS | Utility-first CSS | -| **Components** | shadcn/ui | Accessible Component Library | -| **Server State** | TanStack Query | Server State Management | -| **Client State** | Zustand | Client State Management | -| **Form State** | React Hook Form + Zod | Form State Management | -| **Validation** | Zod | Schema Validation | -| **Testing** | Vitest + Playwright | Unit + E2E Testing | +| Component | Technology | Purpose | +| ---------------- | ------------------------ | ---------------------------- | +| **Framework** | Next.js 14+ (App Router) | React Framework with SSR | +| **Language** | TypeScript (ESM) | Type-safe JavaScript | +| **Styling** | Tailwind CSS + PostCSS | Utility-first CSS | +| **Components** | shadcn/ui | Accessible Component Library | +| **Server State** | TanStack Query | Server State Management | +| **Client State** | Zustand | Client State Management | +| **Form State** | React Hook Form + Zod | Form State Management | +| **Validation** | Zod | Schema Validation | +| **Testing** | Vitest + Playwright | Unit + E2E Testing | ### Backend Stack @@ -274,6 +274,7 @@ Layer 6: File Security (Virus Scanning, Access Control) - **Counter Key:** Composite PK (8 columns) **Documentation:** + - 📋 [Requirements](../01-requirements/01-03.11-document-numbering.md) - 📘 [Implementation Guide](../03-implementation/03-04-document-numbering.md) - 📗 [Operations Guide](../04-operations/04-08-document-numbering-operations.md) diff --git a/specs/03-Data-and-Storage/.mountcheck.js b/specs/03-Data-and-Storage/.mountcheck.js index 809be0d..07c74ed 100644 --- a/specs/03-Data-and-Storage/.mountcheck.js +++ b/specs/03-Data-and-Storage/.mountcheck.js @@ -9,37 +9,41 @@ try { if (!fs.existsSync(config.SOURCE_PDF_DIR)) { throw new Error(`PDF Source directory not found at: ${config.SOURCE_PDF_DIR}`); } - + const files = fs.readdirSync(config.SOURCE_PDF_DIR); - + // Check write permission to log path fs.writeFileSync(`${config.LOG_PATH}/.preflight_ok`, new Date().toISOString()); - + // Grab categories out of the previous node (Fetch Categories) if available // otherwise use fallback array - let categories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other']; + let categories = ['Correspondence', 'RFA', 'Drawing', 'Transmittal', 'Report', 'Other']; try { const upstreamData = $('Fetch Categories').first()?.json?.data; if (upstreamData && Array.isArray(upstreamData)) { - categories = upstreamData.map(c => c.name || c.type || c); // very loose mapping depending on API response + categories = upstreamData.map((c) => c.name || c.type || c); // very loose mapping depending on API response } - } catch(e) {} - + } catch (e) {} + // Grab existing tags from Fetch Tags node let existingTags = []; try { const tagData = $('Fetch Tags').first()?.json?.data || []; - existingTags = Array.isArray(tagData) ? tagData.map(t => t.tag_name || t.name || '').filter(Boolean) : []; - } catch(e) {} - - return [{ json: { - preflight_ok: true, - pdf_count_in_source: files.length, - excel_target: config.EXCEL_FILE, - system_categories: categories, - existing_tags: existingTags, - timestamp: new Date().toISOString() - }}]; + existingTags = Array.isArray(tagData) ? tagData.map((t) => t.tag_name || t.name || '').filter(Boolean) : []; + } catch (e) {} + + return [ + { + json: { + preflight_ok: true, + pdf_count_in_source: files.length, + excel_target: config.EXCEL_FILE, + system_categories: categories, + existing_tags: existingTags, + timestamp: new Date().toISOString(), + }, + }, + ]; } catch (err) { throw new Error(`Pre-flight check failed: ${err.message}`); -} \ No newline at end of file +} diff --git a/specs/03-Data-and-Storage/0.md b/specs/03-Data-and-Storage/0.md index 2d500ae..b2c3de2 100644 --- a/specs/03-Data-and-Storage/0.md +++ b/specs/03-Data-and-Storage/0.md @@ -1,46 +1,56 @@ ตารางสรุปหน้าที่และผลลัพธ์ (Output) ของแต่ละ Node ใน LCBP3 Migration Workflow v1.8.1 คแบ่งกลุ่มตามขั้นตอนการทำงานเพื่อให้เข้าใจได้ง่ายขึ้นครับ: + ## 🚀 กลุ่มที่ 1: จุดเริ่มต้นและเตรียมการ (Initialization & Preflight) -| ชือ Node | หน้าที่ (Function) | ผลลัพธ์ (Output) | -| -------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------- | -| Form Trigger | จุดเริ่มต้นของ Workflow แสดงฟอร์มให้ผู้ใช้เลือกโมเดล AI, ขนาด Batch และระบุตำแหน่งไฟล์ Excel | ข้อมูลจากผู้ใช้ (Model, Batch Size, Excel Path) | -| Set Configuration | ตั้งค่าตัวแปรระบบ (Config) เช่น URL, Token, โฟลเดอร์ทำงาน และเกณฑ์การตัดสินใจของ AI | ชุดตัวแปร config ไว้ใช้ตลอด Workflow | -| Check Backend Health | เรียก API ทดสอบว่าระบบ Backend พร้อมทำงานหรือไม่ | สถานะ HTTP 200 (OK) | -| Fetch Categories | ดึงข้อมูล Master Data หมวดหมู่เอกสารจาก Backend | รายการหมวดหมู่เอกสารทั้งหมดในระบบ | -| Fetch Tags | ดึงข้อมูล Master Data แท็กที่มีอยู่จาก Backend | รายชื่อแท็กทั้งหมดในระบบ | -| File Mount Check | ตรวจสอบว่าไฟล์ Excel และโฟลเดอร์ PDF มีอยู่จริง และเช็คสิทธิ์การเขียนไฟล์ Log | สถานะ preflight_ok: true พร้อมรายชื่อหมวดหมู่/แท็กที่ดึงมาได้ | + +| ชือ Node | หน้าที่ (Function) | ผลลัพธ์ (Output) | +| -------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| Form Trigger | จุดเริ่มต้นของ Workflow แสดงฟอร์มให้ผู้ใช้เลือกโมเดล AI, ขนาด Batch และระบุตำแหน่งไฟล์ Excel | ข้อมูลจากผู้ใช้ (Model, Batch Size, Excel Path) | +| Set Configuration | ตั้งค่าตัวแปรระบบ (Config) เช่น URL, Token, โฟลเดอร์ทำงาน และเกณฑ์การตัดสินใจของ AI | ชุดตัวแปร config ไว้ใช้ตลอด Workflow | +| Check Backend Health | เรียก API ทดสอบว่าระบบ Backend พร้อมทำงานหรือไม่ | สถานะ HTTP 200 (OK) | +| Fetch Categories | ดึงข้อมูล Master Data หมวดหมู่เอกสารจาก Backend | รายการหมวดหมู่เอกสารทั้งหมดในระบบ | +| Fetch Tags | ดึงข้อมูล Master Data แท็กที่มีอยู่จาก Backend | รายชื่อแท็กทั้งหมดในระบบ | +| File Mount Check | ตรวจสอบว่าไฟล์ Excel และโฟลเดอร์ PDF มีอยู่จริง และเช็คสิทธิ์การเขียนไฟล์ Log | สถานะ preflight_ok: true พร้อมรายชื่อหมวดหมู่/แท็กที่ดึงมาได้ | + ## 📂 กลุ่มที่ 2: เตรียมข้อมูลและการแบ่งชุด (Data Ingestion & Batching) -| ชือ Node | หน้าที่ (Function) | ผลลัพธ์ (Output) | -| ------------------------ | -------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| Read Checkpoint | อ่านฐานข้อมูลว่าครั้งที่แล้วประมวลผล Excel ถึงบรรทัดที่เท่าไหร่ (Resume capability) | ตัวเลข last_processed_index ล่าสุด | -| Read Excel Binary | อ่านไฟล์ Excel ต้นฉบับขึ้นมาเป็นข้อมูลไบนารี | ข้อมูล Binary ของ Excel | -| Read Excel | แปลงข้อมูล Binary ให้เป็นตารางข้อมูล JSON | JSON Array ของข้อมูลเอกสารทุกแถว | -| Process Batch + Encoding | ตัดแบ่งแถวตามจำนวน BATCH_SIZE เริ่มจากจุด Checkpoint และแปลง Encoding ให้รองรับภาษาไทย (UTF-8) | ข้อมูลเอกสาร 1 ชุด (เช่น 2 รายการ) ที่พร้อมทำงาน | -| File Validator | ตรวจสอบว่าไฟล์ PDF ที่ระบุใน Excel มีอยู่จริงในโฟลเดอร์ ป้องกัน Path Traversal | เฉพาะรายการที่มีไฟล์ PDF อยู่จริง (รายการ Error จะถูกตัดและส่งไป Log) | + +| ชือ Node | หน้าที่ (Function) | ผลลัพธ์ (Output) | +| ------------------------ | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| Read Checkpoint | อ่านฐานข้อมูลว่าครั้งที่แล้วประมวลผล Excel ถึงบรรทัดที่เท่าไหร่ (Resume capability) | ตัวเลข last_processed_index ล่าสุด | +| Read Excel Binary | อ่านไฟล์ Excel ต้นฉบับขึ้นมาเป็นข้อมูลไบนารี | ข้อมูล Binary ของ Excel | +| Read Excel | แปลงข้อมูล Binary ให้เป็นตารางข้อมูล JSON | JSON Array ของข้อมูลเอกสารทุกแถว | +| Process Batch + Encoding | ตัดแบ่งแถวตามจำนวน BATCH_SIZE เริ่มจากจุด Checkpoint และแปลง Encoding ให้รองรับภาษาไทย (UTF-8) | ข้อมูลเอกสาร 1 ชุด (เช่น 2 รายการ) ที่พร้อมทำงาน | +| File Validator | ตรวจสอบว่าไฟล์ PDF ที่ระบุใน Excel มีอยู่จริงในโฟลเดอร์ ป้องกัน Path Traversal | เฉพาะรายการที่มีไฟล์ PDF อยู่จริง (รายการ Error จะถูกตัดและส่งไป Log) | + ## 🧠 กลุ่มที่ 3: สกัดข้อความและวิเคราะห์ด้วย AI (Text Extraction & AI Analysis) -| ชือ Node | หน้าที่ (Function) | ผลลัพธ์ (Output) | -| ---------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| Read PDF File | อ่านไฟล์ PDF ของรายการที่ผ่านเข้ามาเป็นข้อมูลไบนารี | ข้อมูล Binary ของไฟล์ PDF | -| Extract PDF Text | ส่งไฟล์ PDF ให้ Apache Tika ทำ OCR / สกัดตัวอักษร | ข้อความดิบ (Text) ที่อ่านได้จากหน้า PDF | -| Check Fallback State | ตรวจสอบใน DB ว่าระบบกำลังอยู่ในโหมดใช้โมเดล AI สำรองหรือไม่ | สถานะ is_fallback_active | -| Fetch DB Context | ดึงข้อมูลโปรเจกต์ แผนก และองค์กร เพื่อใช้เป็นบริบทให้ AI อ้างอิง | ข้อมูลอ้างอิงรหัสและชื่อต่างๆ จากระบบเก่า | -| Build AI Prompt | ประกอบร่างข้อความ (Prompt) โดยรวมข้อมูลจาก Excel, ข้อความใน PDF และบริบท เพื่อสั่งงาน AI | คำสั่งในฟิลด์ ollama_payload | -| Ollama AI Analysis | ส่ง Prompt ยิงเข้า Server Ollama เพื่อให้ AI วิเคราะห์ จัดหมวดหมู่ และสรุปข้อมูล | ข้อความอธิบายหรือ JSON ที่ AI ตอบกลับมา | + +| ชือ Node | หน้าที่ (Function) | ผลลัพธ์ (Output) | +| ---------------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| Read PDF File | อ่านไฟล์ PDF ของรายการที่ผ่านเข้ามาเป็นข้อมูลไบนารี | ข้อมูล Binary ของไฟล์ PDF | +| Extract PDF Text | ส่งไฟล์ PDF ให้ Apache Tika ทำ OCR / สกัดตัวอักษร | ข้อความดิบ (Text) ที่อ่านได้จากหน้า PDF | +| Check Fallback State | ตรวจสอบใน DB ว่าระบบกำลังอยู่ในโหมดใช้โมเดล AI สำรองหรือไม่ | สถานะ is_fallback_active | +| Fetch DB Context | ดึงข้อมูลโปรเจกต์ แผนก และองค์กร เพื่อใช้เป็นบริบทให้ AI อ้างอิง | ข้อมูลอ้างอิงรหัสและชื่อต่างๆ จากระบบเก่า | +| Build AI Prompt | ประกอบร่างข้อความ (Prompt) โดยรวมข้อมูลจาก Excel, ข้อความใน PDF และบริบท เพื่อสั่งงาน AI | คำสั่งในฟิลด์ ollama_payload | +| Ollama AI Analysis | ส่ง Prompt ยิงเข้า Server Ollama เพื่อให้ AI วิเคราะห์ จัดหมวดหมู่ และสรุปข้อมูล | ข้อความอธิบายหรือ JSON ที่ AI ตอบกลับมา | | Parse & Validate AI Response | แปลงคำตอบ AI เป็น JSON Object ตรวจสอบว่าโครงสร้างถูกต้อง และจัดรูปแบบให้ตรงกับ Backend | ข้อมูลเดิม + ผลลัพธ์ ai_result (หรือ parse_error ถ้า AI ตอบผิดรูปแบบ) | -| Update Fallback State | นับจำนวน Error ลง DB หาก AI ทำงานพลาดหลายครั้ง ระบบจะสลับไปใช้ Fallback Model โดยอัตโนมัติ | อัปเดตตาราง migration_fallback_state สำเร็จ | +| Update Fallback State | นับจำนวน Error ลง DB หาก AI ทำงานพลาดหลายครั้ง ระบบจะสลับไปใช้ Fallback Model โดยอัตโนมัติ | อัปเดตตาราง migration_fallback_state สำเร็จ | + ## 🔀 กลุ่มที่ 4: การตัดสินใจและการนำเข้าระบบ (Routing & Ingestion) -| ชือ Node | หน้าที่ (Function) | ผลลัพธ์ (Output) | -| ------------------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------- | + +| ชือ Node | หน้าที่ (Function) | ผลลัพธ์ (Output) | +| ------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | | Confidence Router | ตรวจประเมินคะแนนความมั่นใจ (Confidence) จาก AI และกำหนดเส้นทาง route_index ให้เอกสาร | สถานะชั่วคราวและค่า route_index (0, 1, 2, 3) | -| Route by Confidence Switch Node | แบ่งเส้นทางข้อมูลออกเป็น 4 ขา ตามค่าจาก Router | กระจายข้อมูลไปทาง Staging(High), Staging(Review), Reject หรือ Error | -| Restore Binary | (หลังแยกสาย) ดึงข้อมูล Binary ของ PDF กลับมาแนบกับข้อมูลอีกครั้งเตรียมอัปโหลด | JSON + Binary PDF ของไฟล์นั้นๆ | -| Upload to Backend | ยิง API นำไฟล์ PDF ฝากไว้ที่ Temp Storage ของ Backend DMS | รหัสไฟล์ temp_attachment_id ของ Backend | -| Build Enqueue Payload | ประกอบร่างข้อมูลผลวิเคราะห์ AI เข้ากับรหัสไฟล์ เพื่อเตรียมโยนเข้าคิว Migration | โครงสร้าง JSON ที่พร้อมส่งเข้า API Queue (enqueue_payload) | -| Enqueue to Review Queue | ยิงข้อมูลเข้า API Backend เพื่อบันทึกเข้าสู่ Review Queue ระบบ DMS | สถานะสำเร็จจากการรับข้อมูลของ Backend | -| Save Checkpoint | บันทึกประวัติลง Database ว่าประมวลผลผ่านเอกสารชุดนี้เรียบร้อยแล้ว | อัปเดต last_processed_index สำเร็จ | -| Delay | หน่วงเวลา (เช่น 2 วินาที) ก่อนวนรอบขึ้นไปทำข้อมูล Batch ถัดไป | วนลูปกลับไปที่จุด Read Checkpoint | +| Route by Confidence Switch Node | แบ่งเส้นทางข้อมูลออกเป็น 4 ขา ตามค่าจาก Router | กระจายข้อมูลไปทาง Staging(High), Staging(Review), Reject หรือ Error | +| Restore Binary | (หลังแยกสาย) ดึงข้อมูล Binary ของ PDF กลับมาแนบกับข้อมูลอีกครั้งเตรียมอัปโหลด | JSON + Binary PDF ของไฟล์นั้นๆ | +| Upload to Backend | ยิง API นำไฟล์ PDF ฝากไว้ที่ Temp Storage ของ Backend DMS | รหัสไฟล์ temp_attachment_id ของ Backend | +| Build Enqueue Payload | ประกอบร่างข้อมูลผลวิเคราะห์ AI เข้ากับรหัสไฟล์ เพื่อเตรียมโยนเข้าคิว Migration | โครงสร้าง JSON ที่พร้อมส่งเข้า API Queue (enqueue_payload) | +| Enqueue to Review Queue | ยิงข้อมูลเข้า API Backend เพื่อบันทึกเข้าสู่ Review Queue ระบบ DMS | สถานะสำเร็จจากการรับข้อมูลของ Backend | +| Save Checkpoint | บันทึกประวัติลง Database ว่าประมวลผลผ่านเอกสารชุดนี้เรียบร้อยแล้ว | อัปเดต last_processed_index สำเร็จ | +| Delay | หน่วงเวลา (เช่น 2 วินาที) ก่อนวนรอบขึ้นไปทำข้อมูล Batch ถัดไป | วนลูปกลับไปที่จุด Read Checkpoint | + ## 🚨 กลุ่มที่ 5: การจัดการข้อผิดพลาด (Error Logging) -| ชือ Node | หน้าที่ (Function) | ผลลัพธ์ (Output) | -| ----------------- | ------------------------------------------------------------------ | ----------------------------------- | -| Log Reject to CSV | หาก AI ให้คะแนนต่ำกว่าเกณฑ์ จะบันทึกเหตุผลทิ้งไว้ในไฟล์ CSV | บรรทัดข้อมูลใน reject_log.csv | -| Log Error to CSV | หากเกิดข้อผิดพลาดในการประมวลผล (เช่น หาไฟล์ไม่เจอ, AI หลอน) จะบันทึกลง CSV | บรรทัดข้อมูลใน error_log.csv | -| Log Error to DB | ยิง API ของ Backend เพื่อบันทึก Error เข้าสู่ Database ส่วนกลาง | ข้อมูล Error ในตาราง migration_errors | + +| ชือ Node | หน้าที่ (Function) | ผลลัพธ์ (Output) | +| ----------------- | -------------------------------------------------------------------------- | ------------------------------------- | +| Log Reject to CSV | หาก AI ให้คะแนนต่ำกว่าเกณฑ์ จะบันทึกเหตุผลทิ้งไว้ในไฟล์ CSV | บรรทัดข้อมูลใน reject_log.csv | +| Log Error to CSV | หากเกิดข้อผิดพลาดในการประมวลผล (เช่น หาไฟล์ไม่เจอ, AI หลอน) จะบันทึกลง CSV | บรรทัดข้อมูลใน error_log.csv | +| Log Error to DB | ยิง API ของ Backend เพื่อบันทึก Error เข้าสู่ Database ส่วนกลาง | ข้อมูล Error ในตาราง migration_errors | diff --git a/specs/03-Data-and-Storage/03-01-data-dictionary.md b/specs/03-Data-and-Storage/03-01-data-dictionary.md index 9ff3164..0d88011 100644 --- a/specs/03-Data-and-Storage/03-01-data-dictionary.md +++ b/specs/03-Data-and-Storage/03-01-data-dictionary.md @@ -1,2221 +1,2221 @@ ---- -title: 'Data & Storage: Data Dictionary and Data Model Architecture' -version: 1.8.0 -status: released -owner: Nattanin Peancharoen -last_updated: 2026-02-28 -related: - - specs/01-requirements/02-architecture.md - - specs/01-requirements/03-functional-requirements.md ---- - -# 1. Data Model Architecture Overview - -## 📋 1.1 Overview -เอกสารนี้อธิบายสถาปัตยกรรมของ Data Model สำหรับระบบ LCBP3-DMS โดยครอบคลุมโครงสร้างฐานข้อมูล, ความสัมพันธ์ระหว่างตาราง, และหลักการออกแบบที่สำคัญ - -## 🎯 1.2 Design Principles -### 1. Separation of Concerns - -- **Master-Revision Pattern**: แยกข้อมูลที่ไม่เปลี่ยนแปลง (Master) จากข้อมูลที่มีการแก้ไข (Revisions) - - `correspondences` (Master) ↔ `correspondence_revisions` (Revisions) - - `rfas` (Master) ↔ `rfa_revisions` (Revisions) - - `shop_drawings` (Master) ↔ `shop_drawing_revisions` (Revisions) - -### 2. Data Integrity - -- **Foreign Key Constraints**: ใช้ FK ทุกความสัมพันธ์เพื่อรักษาความสมบูรณ์ของข้อมูล -- **Soft Delete**: ใช้ `deleted_at` แทนการลบข้อมูลจริง เพื่อรักษาประวัติ -- **Optimistic Locking**: ใช้ `version` column ใน `document_number_counters` ป้องกัน Race Condition - -### 3. Flexibility & Extensibility - -- **JSON Details Field**: เก็บข้อมูลเฉพาะประเภทใน `correspondence_revisions.details` -- **Virtual Columns**: สร้าง Index จาก JSON fields สำหรับ Performance -- **Master Data Tables**: แยกข้อมูล Master (Types, Status, Codes) เพื่อความยืดหยุ่น - -### 4. Security & Audit - -- **RBAC (Role-Based Access Control)**: ระบบสิทธิ์แบบ Hierarchical Scope -- **Audit Trail**: บันทึกผู้สร้าง/แก้ไข และเวลาในทุกตาราง -- **Two-Phase File Upload**: ป้องกันไฟล์ขยะด้วย Temporary Storage - -# 2. Database Schema Overview (ERD) -### Entity Relationship Diagram - -```mermaid -erDiagram - %% Core Entities - organizations ||--o{ users : "employs" - projects ||--o{ contracts : "contains" - projects ||--o{ correspondences : "manages" - - %% RBAC - users ||--o{ user_assignments : "has" - roles ||--o{ user_assignments : "assigned_to" - roles ||--o{ role_permissions : "has" - permissions ||--o{ role_permissions : "granted_by" - - %% Correspondences - correspondences ||--o{ correspondence_revisions : "has_revisions" - correspondence_types ||--o{ correspondences : "categorizes" - correspondence_status ||--o{ correspondence_revisions : "defines_state" - disciplines ||--o{ correspondences : "classifies" - - %% RFAs - rfas ||--o{ rfa_revisions : "has_revisions" - rfa_types ||--o{ rfas : "categorizes" - rfa_status_codes ||--o{ rfa_revisions : "defines_state" - rfa_approve_codes ||--o{ rfa_revisions : "defines_result" - disciplines ||--o{ rfas : "classifies" - - %% Drawings - shop_drawings ||--o{ shop_drawing_revisions : "has_revisions" - shop_drawing_main_categories ||--o{ shop_drawings : "categorizes" - shop_drawing_sub_categories ||--o{ shop_drawings : "sub_categorizes" - - %% Attachments - attachments ||--o{ correspondence_attachments : "attached_to" - correspondences ||--o{ correspondence_attachments : "has" -``` - ---- - -# 3. Data Dictionary V1.8.0 - -> หมายเหตุ: PK = Primary Key, FK = Foreign Key, AI = AUTO_INCREMENT. รูปแบบ Soft Delete จะปรากฏ Column `deleted_at DATETIME NULL` เป็นมาตรฐาน - -## **1. 🏢 Core & Master Data Tables (องค์กร, โครงการ, สัญญา)** - -### 1.1 organization_roles - -* * Purpose **: MASTER TABLE FOR organization role TYPES IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ----------- | ----------- | --------------------------- | ---------------------------------------------------------------- | -| id | INT | PRIMARY KEY, -AUTO_INCREMENT | UNIQUE identifier FOR organization role | | role_name | VARCHAR(20) | NOT NULL, -UNIQUE | Role name ( - CONTRACTOR, - THIRD PARTY -) | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (role_name) ** Business Rules **: - Predefined system roles FOR organization TYPES - Cannot be deleted IF referenced by organizations --- - -### 1.2 organizations - -* * Purpose **: MASTER TABLE storing ALL organizations involved IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ----------------- | ------------ | ----------------------------------- | ---------------------------------------- | -| id | INT | PRIMARY KEY, -AUTO_INCREMENT | UNIQUE identifier FOR organization | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | organization_code | VARCHAR(20) | NOT NULL, -UNIQUE | Organization code (e.g., 'กทท.', 'TEAM') | | organization_name | VARCHAR(255) | NOT NULL | FULL organization name | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS (1 = active, 0 = inactive) | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last -UPDATE timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (organization_code) - INDEX (is_active) ** Relationships **: - Referenced by: users, - project_organizations, - contract_organizations, - correspondences, - circulations --- - - ### 1.3 projects - - * * Purpose **: MASTER TABLE FOR ALL projects IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ------------ | ------------ | --------------------------- | ----------------------------- | - | id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier FOR project | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | project_code | VARCHAR(50) | NOT NULL, - UNIQUE | Project code (e.g., 'LCBP3') | | project_name | VARCHAR(255) | NOT NULL | FULL project name | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | -** INDEXES **: - PRIMARY KEY (id) - UNIQUE (project_code) - INDEX (is_active) ** Relationships **: - Referenced by: contracts, - correspondences, - document_number_formats, - drawings --- - - ### 1.4 contracts - - * * Purpose **: MASTER TABLE FOR contracts within projects | COLUMN Name | Data TYPE | Constraints | Description | | ------------- | ------------ | ----------------------------------- | ------------------------------ | - | id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier FOR contract | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | project_id | INT | NOT NULL, - FK | Reference TO projects TABLE | | contract_code | VARCHAR(50) | NOT NULL, - UNIQUE | Contract code | | contract_name | VARCHAR(255) | NOT NULL | FULL contract name | | description | TEXT | NULL | Contract description | | start_date | DATE | NULL | Contract START date | | end_date | DATE | NULL | Contract -END date | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last -UPDATE timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (contract_code) - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - INDEX (project_id, is_active) ** Relationships **: - Parent: projects - Referenced by: contract_organizations, - user_assignments --- - - ### 1.5 disciplines (NEW v1.5.1) - - * * Purpose **: เก็บข้อมูลสาขางาน (Disciplines) แยกตามสัญญา (Req 6B) | COLUMN Name | Data TYPE | Constraints | Description | |: -------------- | :----------- | :----------- | :--------------------- | - | id | INT | PK, - AI | UNIQUE identifier | | contract_id | INT | FK, - NOT NULL | ผูกกับสัญญา | | discipline_code | VARCHAR(10) | NOT NULL | รหัสสาขา (เช่น GEN, STR) | | code_name_th | VARCHAR(255) | NULL | ชื่อไทย | | code_name_en | VARCHAR(255) | NULL | ชื่ออังกฤษ | | is_active | TINYINT(1) | DEFAULT 1 | สถานะการใช้งาน | ** INDEXES **: - UNIQUE (contract_id, discipline_code) --- - - ## **2. 👥 Users & RBAC Tables (ผู้ใช้, สิทธิ์, บทบาท)** - - ### 2.1 users - - * * Purpose **: MASTER TABLE storing ALL system users | COLUMN Name | Data TYPE | Constraints | Description | | ----------------------- | ------------ | ----------------------------------- | -------------------------------- | - | user_id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier FOR user | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | username | VARCHAR(50) | NOT NULL, - UNIQUE | Login username | | password_hash | VARCHAR(255) | NOT NULL | Hashed PASSWORD (bcrypt) | | first_name | VARCHAR(50) | NULL | User 's first name | -| last_name | VARCHAR(50) | NULL | User' s last name | | email | VARCHAR(100) | NOT NULL, - UNIQUE | Email address | | line_id | VARCHAR(100) | NULL | LINE messenger ID | | primary_organization_id | INT | NULL, - FK | PRIMARY organization affiliation | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | | failed_attempts | INT | DEFAULT 0 | Failed login attempts counter | | locked_until | DATETIME | NULL | Account LOCK expiration time | | last_login_at | TIMESTAMP | NULL | Last successful login timestamp | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last -UPDATE timestamp | | deleted_at | DATETIME | NULL | Deleted at | ** INDEXES **: - PRIMARY KEY (user_id) - UNIQUE (username) - UNIQUE (email) - FOREIGN KEY (primary_organization_id) REFERENCES organizations(id) ON DELETE -SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: organizations (primary_organization_id) - Referenced by: user_assignments, - audit_logs, - notifications, - circulation_routings --- - - ### 2.2 roles - - * * Purpose **: MASTER TABLE defining system roles WITH scope levels | COLUMN Name | Data TYPE | Constraints | Description | | ----------- | ------------ | --------------------------- | ---------------------------------------------------- | - | role_id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier FOR role | | role_name | VARCHAR(100) | NOT NULL | Role name (e.g., 'Superadmin', 'Document Control') | | scope | ENUM | NOT NULL | Scope LEVEL: GLOBAL, - Organization, - Project, - Contract | | description | TEXT | NULL | Role description | | is_system | BOOLEAN | DEFAULT FALSE | System role flag (cannot be deleted) | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | -** INDEXES **: - PRIMARY KEY (role_id) - INDEX (scope) ** Relationships **: - Referenced by: role_permissions, - user_assignments --- - - ### 2.3 permissions - - * * Purpose **: MASTER TABLE defining ALL system permissions | COLUMN Name | Data TYPE | Constraints | Description | | --------------- | ------------ | --------------------------- | ------------------------------------------------------ | - | permission_id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier FOR permission | | permission_name | VARCHAR(100) | NOT NULL, - UNIQUE | Permission code (e.g., 'rfas.create', 'document.view') | | description | TEXT | NULL | Permission description | | module | VARCHAR(50) | NULL | Related module name | | scope_level | ENUM | NULL | Scope: GLOBAL, - ORG, - PROJECT | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | -** INDEXES **: - PRIMARY KEY (permission_id) - UNIQUE (permission_name) - INDEX (module) - INDEX (scope_level) - INDEX (is_active) ** Relationships **: - Referenced by: role_permissions --- - - ### 2.4 role_permissions - - * * Purpose **: Junction TABLE mapping roles TO permissions (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | ------------- | --------- | --------------- | ------------------------------ | - | role_id | INT | PRIMARY KEY, - FK | Reference TO roles TABLE | | permission_id | INT | PRIMARY KEY, - FK | Reference TO permissions TABLE | ** INDEXES **: - PRIMARY KEY (role_id, permission_id) - FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE - FOREIGN KEY (permission_id) REFERENCES permissions(permission_id) ON DELETE CASCADE - INDEX (permission_id) ** Relationships **: - Parent: roles, - permissions --- - - ### 2.5 user_assignments - - * * Purpose **: Junction TABLE assigning users TO roles WITH scope context | COLUMN Name | Data TYPE | Constraints | Description | | ------------------- | --------- | --------------------------- | ---------------------------------- | - | id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier | | user_id | INT | NOT NULL, - FK | Reference TO users TABLE | | role_id | INT | NOT NULL, - FK | Reference TO roles TABLE | | organization_id | INT | NULL, - FK | Organization scope (IF applicable) | | project_id | INT | NULL, - FK | Project scope (IF applicable) | | contract_id | INT | NULL, - FK | Contract scope (IF applicable) | | assigned_by_user_id | INT | NULL, - FK | User who made the assignment | | assigned_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Assignment timestamp | ** INDEXES **: - PRIMARY KEY (id) - FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE - FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE - FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE - FOREIGN KEY (assigned_by_user_id) REFERENCES users(user_id) - INDEX (user_id, role_id) - INDEX (organization_id) - INDEX (project_id) - INDEX (contract_id) ** Relationships **: - Parent: users, - roles, - organizations, - projects, - contracts --- - - ### 2.6 project_organizations - - * * Purpose **: Junction TABLE linking projects TO participating organizations (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | --------------- | --------- | --------------- | -------------------------------- | - | project_id | INT | PRIMARY KEY, - FK | Reference TO projects TABLE | | organization_id | INT | PRIMARY KEY, - FK | Reference TO organizations TABLE | ** INDEXES **: - PRIMARY KEY (project_id, organization_id) - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE - INDEX (organization_id) ** Relationships **: - Parent: projects, - organizations --- - - ### 2.7 contract_organizations - - * * Purpose **: Junction TABLE linking contracts TO participating organizations WITH roles (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | ---------------- | ------------ | --------------- | ------------------------------------------------------------------------- | - | contract_id | INT | PRIMARY KEY, - FK | Reference TO contracts TABLE | | organization_id | INT | PRIMARY KEY, - FK | Reference TO organizations TABLE | | role_in_contract | VARCHAR(100) | NULL | Organization 's role in contract (Owner, Designer, Consultant, Contractor) | - -**Indexes**: - -* PRIMARY KEY (contract_id, organization_id) -* FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE -* FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE -* INDEX (organization_id) -* INDEX (role_in_contract) - -**Relationships**: - -* Parent: contracts, organizations - ---- - -### 2.8 user_preferences (NEW v1.5.1) - -**Purpose**: เก็บการตั้งค่าส่วนตัวของผู้ใช้ (Req 5.5, 6.8.3) - -| Column Name | Data Type | Constraints | Description | -| :----------- | :---------- | :---------------- | :-------------- | -| user_id | INT | PK, FK | User ID | -| notify_email | BOOLEAN | DEFAULT TRUE | รับอีเมลแจ้งเตือน | -| notify_line | BOOLEAN | DEFAULT TRUE | รับไลน์แจ้งเตือน | -| digest_mode | BOOLEAN | DEFAULT FALSE | รับแจ้งเตือนแบบรวม | -| ui_theme | VARCHAR(20) | DEFAULT ' light ' | UI Theme | - ---- - -### 2.9 refresh_tokens (NEW v1.5.1) - -**Purpose**: เก็บ Refresh Tokens สำหรับการทำ Authentication และ Token Rotation - -| Column Name | Data Type | Constraints | Description | -| :---------------- | :----------- | :------------------------ | :------------------------------------ | -| token_id | INT | PK, AI | Unique Token ID | -| user_id | INT | FK, NOT NULL | เจ้าของ Token | -| token_hash | VARCHAR(255) | NOT NULL | Hash ของ Refresh Token (Security) | -| expires_at | DATETIME | NOT NULL | วันหมดอายุของ Token | -| is_revoked | BOOLEAN | DEFAULT FALSE | สถานะถูกยกเลิก (True = ใช้งานไม่ได้) | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | เวลาที่สร้าง | -| replaced_by_token | VARCHAR(255) | NULL | Token ใหม่ที่มาแทนที่ (กรณี Token Rotation) | - -**Indexes**: - -* PRIMARY KEY (token_id) -* FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE -* INDEX (user_id) - -**Relationships**: - -* Parent: users - ---- - -## **3. ✉️ Correspondences Tables (เอกสารหลัก, Revisions, Workflows)** - -### 3.1 correspondence_types - -**Purpose**: Master table for correspondence document types - -| Column Name | Data Type | Constraints | Description | -| ----------- | ------------ | --------------------------- | --------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique identifier | -| type_code | VARCHAR(50) | NOT NULL, UNIQUE | Type code (e.g., ' RFA ', ' RFI ', ' TRANSMITTAL ') | -| type_name | VARCHAR(255) | NOT NULL | Full type name | -| sort_order | INT | DEFAULT 0 | Display order | -| is_active | TINYINT(1) | DEFAULT 1 | Active status | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | - -**Indexes**: - -* PRIMARY KEY (id) -* UNIQUE (type_code) -* INDEX (is_active) -* INDEX (sort_order) - -**Relationships**: - -* Referenced by: correspondences, document_number_formats, document_number_counters - ---- - -### 3.2 correspondence_sub_types (NEW v1.5.1) - -**Purpose**: เก็บประเภทหนังสือย่อย (Sub Types) สำหรับ Mapping เลขรหัส (Req 6B) - -| Column Name | Data Type | Constraints | Description | -| :--------------------- | :----------- | :----------- | :------------------------ | -| id | INT | PK, AI | Unique identifier | -| contract_id | INT | FK, NOT NULL | ผูกกับสัญญา | -| correspondence_type_id | INT | FK, NOT NULL | ผูกกับประเภทเอกสารหลัก | -| sub_type_code | VARCHAR(20) | NOT NULL | รหัสย่อย (เช่น MAT, SHP) | -| sub_type_name | VARCHAR(255) | NULL | ชื่อประเภทหนังสือย่อย | -| sub_type_number | VARCHAR(10) | NULL | เลขรหัสสำหรับ Running Number | - ---- - -### 3.3 correspondences (UPDATE v1.7.0) - -**Purpose**: Master table for correspondence documents (non-revisioned data) - -| Column Name | Data Type | Constraints | Description | -| ------------------------- | ------------ | --------------------------- | ------------------------------------------ | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Master correspondence ID | -| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | -| correspondence_number | VARCHAR(100) | NOT NULL | Document number (from numbering system) | -| correspondence_type_id | INT | NOT NULL, FK | Reference to correspondence_types | -| **discipline_id** | **INT** | **NULL, FK** | **[NEW] สาขางาน (ถ้ามี)** | -| is_internal_communication | TINYINT(1) | DEFAULT 0 | Internal (1) or external (0) communication | -| project_id | INT | NOT NULL, FK | Reference to projects table | -| originator_id | INT | NULL, FK | Originating organization | -| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| created_by | INT | NULL, FK | User who created the record | -| deleted_at | DATETIME | NULL | Soft delete timestamp | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE RESTRICT -* **FOREIGN KEY (discipline_id) REFERENCES disciplines(id) ON DELETE SET NULL** -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* FOREIGN KEY (originator_id) REFERENCES organizations(id) ON DELETE SET NULL -* FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL -* UNIQUE KEY (project_id, correspondence_number) -* UNIQUE INDEX idx_correspondences_uuid (uuid) -* INDEX (correspondence_type_id) -* INDEX (originator_id) -* INDEX (deleted_at) - -**Relationships**: - -* Parent: correspondence_types, **disciplines**, projects, organizations, users -* Children: correspondence_revisions, correspondence_recipients, correspondence_tags, correspondence_references, correspondence_attachments, circulations, transmittals - ---- - -### 3.4 correspondence_revisions (UPDATE v1.7.0) - -**Purpose**: Child table storing revision history of correspondences (1:N) - -| Column Name | Data Type | Constraints | Description | -| ------------------------ | ------------ | --------------------------------- | -------------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | -| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | -| correspondence_id | INT | NOT NULL, FK | Master correspondence ID | -| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | -| revision_label | VARCHAR(10) | NULL | Display revision (A, B, 1.1...) | -| is_current | BOOLEAN | DEFAULT FALSE | Current revision flag | -| correspondence_status_id | INT | NOT NULL, FK | Current status of this revision | -| title | VARCHAR(255) | NOT NULL | Document title | -| document_date | DATE | NULL | Document date | -| issued_date | DATETIME | NULL | Issue date | -| received_date | DATETIME | NULL | Received date | -| due_date | DATETIME | NULL | Due date for response | -| description | TEXT | NULL | Revision description | -| details | JSON | NULL | Type-specific details (e.g., RFI questions) | -| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | -| created_by | INT | NULL, FK | User who created revision | -| updated_by | INT | NULL, FK | User who last updated | -| v_ref_project_id | INT | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Project ID จาก JSON details เพื่อทำ Index | - -| v_doc_subtype | VARCHAR(50) | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Type จาก JSON details | -| schema_version | INT | DEFAULT 1 | Version of the schema used with this details | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (correspondence_status_id) REFERENCES correspondence_status(id) ON DELETE RESTRICT -* FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL -* FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL -* UNIQUE KEY (correspondence_id, revision_number) -* UNIQUE KEY (correspondence_id, is_current) -* INDEX (correspondence_status_id) -* INDEX (is_current) -* INDEX (document_date) -* INDEX (issued_date) -* INDEX (v_ref_project_id) -* INDEX (v_doc_subtype) - ---- - -### 3.5 correspondence_recipients - -**Purpose**: Junction table for correspondence recipients (TO/CC) (M:N) - -| Column Name | Data Type | Constraints | Description | -| ------------------------- | -------------------- | --------------- | ---------------------------- | -| correspondence_id | INT | PRIMARY KEY, FK | Reference to correspondences | -| recipient_organization_id | INT | PRIMARY KEY, FK | Recipient organization | -| recipient_type | ENUM(' TO ', ' CC ') | PRIMARY KEY | Recipient type | - -**Indexes**: - -* PRIMARY KEY (correspondence_id, recipient_organization_id, recipient_type) -* FOREIGN KEY (correspondence_id) REFERENCES correspondence_revisions(correspondence_id) ON DELETE CASCADE -* FOREIGN KEY (recipient_organization_id) REFERENCES organizations(id) ON DELETE RESTRICT -* INDEX (recipient_organization_id) -* INDEX (recipient_type) - -**Relationships**: - -* Parent: correspondences, organizations - ---- - -### 3.6 tags (UPDATE v1.8.0) - -**Purpose**: Master table for document tagging system (Supports multi-tenant per project) - -| Column Name | Data Type | Constraints | Description | -| -------------- | --------------- | ----------------------------------- | --------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique tag ID | -| **project_id** | **INT** | **NULL, FK** | **[NEW] Project scope (NULL = Global)** | -| tag_name | VARCHAR(100) | NOT NULL | Tag name | -| **color_code** | **VARCHAR(30)** | **DEFAULT 'default'** | **[NEW] UI Color/Class Code** | -| description | TEXT | NULL | Tag description | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| **created_by** | **INT** | **NULL, FK** | **[NEW] User who created the tag** | -| **deleted_at** | **DATETIME** | **NULL** | **[NEW] Soft delete timestamp** | - -**Indexes**: - -* PRIMARY KEY (id) -* **FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE** -* **FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL** -* **UNIQUE KEY (project_id, tag_name)** -* INDEX (deleted_at) - -**Relationships**: - -* Parent: projects, users -* Referenced by: correspondence_tags - ---- - -### 3.7 correspondence_tags (UPDATE v1.8.0) - -**Purpose**: Junction table linking correspondences to tags (M:N) - -| Column Name | Data Type | Constraints | Description | -| ----------------- | --------- | --------------- | ---------------------------- | -| correspondence_id | INT | PRIMARY KEY, FK | Reference to correspondences | -| tag_id | INT | PRIMARY KEY, FK | Reference to tags | - -**Indexes**: - -* PRIMARY KEY (correspondence_id, tag_id) -* FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE -* **INDEX idx_tag_lookup (tag_id) - For reverse lookup (Find documents by tag)** - -**Relationships**: - -* Parent: correspondences, tags - ---- - -### 3.8 correspondence_references - -**Purpose**: Junction table for cross-referencing correspondences (M:N) - -| Column Name | Data Type | Constraints | Description | -| --------------------- | --------- | --------------- | ------------------------------------- | -| src_correspondence_id | INT | PRIMARY KEY, FK | Source correspondence ID | -| tgt_correspondence_id | INT | PRIMARY KEY, FK | Target (referenced) correspondence ID | - -**Indexes**: - -* PRIMARY KEY (src_correspondence_id, tgt_correspondence_id) -* FOREIGN KEY (src_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (tgt_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* INDEX (tgt_correspondence_id) - -**Relationships**: - -* Parent: correspondences (both sides) - ---- - -## **4. 📐 approval: RFA Tables (เอกสารขออนุมัติ, Workflows)** - -### 4.1 rfa_types (UPDATE v1.7.0) - -**Purpose**: Master table for RFA (Request for Approval) types - -| Column Name | Data Type | Constraints | Description | -| :----------- | :----------- | :-------------------------- | :------------------------------ | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique identifier | -| contract_id | INT | NOT NULL, FK | Contract reference | -| type_code | VARCHAR(20) | NOT NULL | Type code (DDW, SDW, ADW, DOC, MAT, etc.) | -| type_name_th | VARCHAR(100) | NOT NULL | Full type name (TH) | -| type_name_en | VARCHAR(100) | NOT NULL | Full type name (EN) | -| remark | TEXT | NULL | Remark | -| is_active | TINYINT(1) | DEFAULT 1 | Active status | - -**Indexes**: - -* PRIMARY KEY (id) -* UNIQUE (contract_id, type_code) -* FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE -* INDEX (is_active) - -**Relationships**: - -* Referenced by: rfas - ---- - -### 4.2 rfa_status_codes - -**Purpose**: Master table for RFA status codes - -| Column Name | Data Type | Constraints | Description | -| ----------- | ------------ | --------------------------- | --------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique identifier | -| status_code | VARCHAR(20) | NOT NULL, UNIQUE | Status code (DFT, FAP, FRE, etc.) | -| status_name | VARCHAR(100) | NOT NULL | Full status name | -| description | TEXT | NULL | Status description | -| sort_order | INT | DEFAULT 0 | Display order | -| is_active | TINYINT(1) | DEFAULT 1 | Active status | - -**Indexes**: - -* PRIMARY KEY (id) -* UNIQUE (status_code) -* INDEX (is_active) -* INDEX (sort_order) - -**Relationships**: - -* Referenced by: rfa_revisions - ---- - -### 4.3 rfa_approve_codes - -**Purpose**: Master table for RFA approval result codes - -| Column Name | Data Type | Constraints | Description | -| ------------ | ------------ | --------------------------- | -------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique identifier | -| approve_code | VARCHAR(20) | NOT NULL, UNIQUE | Approval code (1A, 1C, 3R, etc.) | -| approve_name | VARCHAR(100) | NOT NULL | Full approval name | -| description | TEXT | NULL | Code description | -| sort_order | INT | DEFAULT 0 | Display order | -| is_active | TINYINT(1) | DEFAULT 1 | Active status | - -**Indexes**: - -* PRIMARY KEY (id) -* UNIQUE (approve_code) -* INDEX (is_active) -* INDEX (sort_order) - -**Relationships**: - -* Referenced by: rfa_revisions - ---- - -### 4.4 rfas (UPDATE v1.7.0) - -**Purpose**: Master table for RFA documents (non-revisioned data) - -| Column Name | Data Type | Constraints | Description | -| :---------- | :-------- | :------------------------ | :------------------------------------------ | -| id | INT | PK, FK | Master RFA ID (Shared with correspondences) | -| rfa_type_id | INT | NOT NULL, FK | Reference to rfa_types | -| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| created_by | INT | NULL, FK | User who created the record | -| deleted_at | DATETIME | NULL | Soft delete timestamp | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (rfa_type_id) REFERENCES rfa_types(id) -* FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL -* INDEX (rfa_type_id) -* INDEX (deleted_at) - -**Relationships**: - -* Parent: correspondences, rfa_types, users -* Children: rfa_revisions - ---- - -### 4.5 rfa_revisions (UPDATE v1.7.0) - -**Purpose**: Child table storing revision history of RFAs (1:N) - -| Column Name | Data Type | Constraints | Description | -| ------------------- | --------- | --------------------------------- | ----------------------------------------------------------- | -| id | INT | PK, FK | Master Revision ID (Shared with correspondence_revisions) | -| rfa_status_code_id | INT | NOT NULL, FK | Current RFA status | -| rfa_approve_code_id | INT | NULL, FK | Approval result code | -| details | JSON | NULL | Type-specific details (e.g., RFI questions) | -| v_ref_drawing_count | INT | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Drawing Count จาก JSON details เพื่อทำ Index | -| schema_version | INT | DEFAULT 1 | Version of the schema used with this details | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (id) REFERENCES correspondence_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (rfa_status_code_id) REFERENCES rfa_status_codes(id) -* FOREIGN KEY (rfa_approve_code_id) REFERENCES rfa_approve_codes(id) ON DELETE SET NULL -* INDEX (rfa_status_code_id) -* INDEX (rfa_approve_code_id) -* INDEX (v_ref_drawing_count): ตัวอย่างการ Index ข้อมูลตัวเลขใน JSON - -**Relationships**: - -* Parent: correspondence_revisions, rfas, rfa_status_codes, rfa_approve_codes -* Children: rfa_items - ---- - -### 4.6 rfa_items - -**Purpose**: Child table linking RFA revisions to drawing revisions that require approval - -| Column Name | Data Type | Constraints | Description | -| :------------------------- | :----------------------- | :------------------- | :--------------------------------- | -| id | INT | PRIMARY KEY, AI | Unique identifier | -| rfa_revision_id | INT | NOT NULL, FK | RFA Revision ID | -| item_type | ENUM('SHOP','AS_BUILT') | NOT NULL | Drawing reference type | -| shop_drawing_revision_id | INT | NULL, FK | Shop drawing revision ID | -| asbuilt_drawing_revision_id| INT | NULL, FK | As-Built drawing revision ID | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (rfa_revision_id) REFERENCES rfa_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE -* UNIQUE KEY (rfa_revision_id, shop_drawing_revision_id) -* UNIQUE KEY (rfa_revision_id, asbuilt_drawing_revision_id) -* INDEX (item_type) -* INDEX (shop_drawing_revision_id) -* INDEX (asbuilt_drawing_revision_id) - -**Relationships**: - -* Parent: rfa_revisions, shop_drawing_revisions, asbuilt_drawing_revisions - -**Business Rules**: - -* `correspondences.correspondence_type_id` for an RFA must always point to `correspondence_types.type_code = 'RFA'` -* `rfas.rfa_type_id` stores the selected RFA subtype -* `DDW` and `SDW` RFA types must reference `shop_drawing_revisions` -* `ADW` RFA types must reference `asbuilt_drawing_revisions` -* Each `rfa_items` row must reference exactly one drawing revision target according to `item_type` -* One RFA can contain multiple drawing references -* One drawing revision can be referenced by multiple RFAs - ---- - - ---- - -## **5. 📐 Drawings Tables (แบบ, หมวดหมู่)** - -### 5.1 contract_drawing_volumes - -**Purpose**: Master table for contract drawing volume classification - -| Column Name | Data Type | Constraints | Description | -| ----------- | ------------ | ----------------------------------- | ------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique volume ID | -| project_id | INT | NOT NULL, FK | Reference to projects | -| volume_code | VARCHAR(50) | NOT NULL | Volume code | -| volume_name | VARCHAR(255) | NOT NULL | Volume name | -| description | TEXT | NULL | Volume description | -| sort_order | INT | DEFAULT 0 | Display order | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* UNIQUE KEY (project_id, volume_code) -* INDEX (sort_order) - -**Relationships**: - -* Parent: projects -* Referenced by: contract_drawings - -**Business Rules**: - -* Volume codes must be unique within a project -* Used for organizing large sets of contract drawings - ---- - -### 5.2 contract_drawing_cats - -**Purpose**: Master table for contract drawing main categories - -| Column Name | Data Type | Constraints | Description | -| ----------- | ------------ | ----------------------------------- | ------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique category ID | -| project_id | INT | NOT NULL, FK | Reference to projects | -| cat_code | VARCHAR(50) | NOT NULL | Category code | -| cat_name | VARCHAR(255) | NOT NULL | Category name | -| description | TEXT | NULL | Category description | -| sort_order | INT | DEFAULT 0 | Display order | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* UNIQUE KEY (project_id, cat_code) -* INDEX (sort_order) - -**Relationships**: - -* Parent: projects -* Referenced by: contract_drawing_subcat_cat_maps - -**Business Rules**: - -* Category codes must be unique within a project -* Hierarchical relationship with sub-categories via mapping table - ---- - -### 5.3 contract_drawing_sub_cats - -**Purpose**: Master table for contract drawing sub-categories - -| Column Name | Data Type | Constraints | Description | -| ------------ | ------------ | ----------------------------------- | ------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique sub-category ID | -| project_id | INT | NOT NULL, FK | Reference to projects | -| sub_cat_code | VARCHAR(50) | NOT NULL | Sub-category code | -| sub_cat_name | VARCHAR(255) | NOT NULL | Sub-category name | -| description | TEXT | NULL | Sub-category description | -| sort_order | INT | DEFAULT 0 | Display order | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* UNIQUE KEY (project_id, sub_cat_code) -* INDEX (sort_order) - -**Relationships**: - -* Parent: projects -* Referenced by: contract_drawings, contract_drawing_subcat_cat_maps - -**Business Rules**: - -* Sub-category codes must be unique within a project -* Can be mapped to multiple main categories via mapping table - ---- - -### 5.4 contract_drawing_subcat_cat_maps (UPDATE v1.7.0) - -**Purpose**: Junction table mapping sub-categories to main categories (M:N) - -| Column Name | Data Type | Constraints | Description | -| ----------- | --------- | ------------------------------- | -------------------------- | -| **id** | **INT** | **PRIMARY KEY, AUTO_INCREMENT** | **Unique mapping ID** | -| project_id | INT | NOT NULL, FK | Reference to projects | -| sub_cat_id | INT | NOT NULL, FK | Reference to sub-category | -| cat_id | INT | NOT NULL, FK | Reference to main category | - -**Indexes**: - -* PRIMARY KEY (id) -* **UNIQUE KEY (project_id, sub_cat_id, cat_id)** -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* FOREIGN KEY (sub_cat_id) REFERENCES contract_drawing_sub_cats(id) ON DELETE CASCADE -* FOREIGN KEY (cat_id) REFERENCES contract_drawing_cats(id) ON DELETE CASCADE -* INDEX (sub_cat_id) -* INDEX (cat_id) - -**Relationships**: - -* Parent: projects, contract_drawing_sub_cats, contract_drawing_cats -* Referenced by: contract_drawings - -**Business Rules**: - -* Allows flexible categorization -* One sub-category can belong to multiple main categories -* Composite uniqueness enforced via UNIQUE constraint - ---- - -### 5.5 contract_drawings (UPDATE v1.7.0) - -**Purpose**: Master table for contract drawings (from contract specifications) - -| Column Name | Data Type | Constraints | Description | -| --------------- | ------------ | ----------------------------------- | ---------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID | -| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | -| project_id | INT | NOT NULL, FK | Reference to projects | -| condwg_no | VARCHAR(255) | NOT NULL | Contract drawing number | -| title | VARCHAR(255) | NOT NULL | Drawing title | -| **map_cat_id** | **INT** | **NULL, FK** | **[CHANGED] Reference to mapping table** | -| volume_id | INT | NULL, FK | Reference to volume | -| **volume_page** | **INT** | **NULL** | **[NEW] Page number within volume** | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | -| updated_by | INT | NULL, FK | User who last updated | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* **FOREIGN KEY (map_cat_id) REFERENCES contract_drawing_subcat_cat_maps(id) ON DELETE RESTRICT** -* FOREIGN KEY (volume_id) REFERENCES contract_drawing_volumes(id) ON DELETE RESTRICT -* FOREIGN KEY (updated_by) REFERENCES users(user_id) -* UNIQUE KEY (project_id, condwg_no) -* UNIQUE INDEX idx_contract_drawings_uuid (uuid) -* INDEX (map_cat_id) -* INDEX (volume_id) -* INDEX (deleted_at) - -**Relationships**: - -* Parent: projects, contract_drawing_subcat_cat_maps, contract_drawing_volumes, users -* Referenced by: shop_drawing_revision_contract_refs, contract_drawing_attachments - -**Business Rules**: - -* Drawing numbers must be unique within a project -* Represents baseline/contract drawings -* Referenced by shop drawings for compliance tracking -* Soft delete preserves history -* **map_cat_id references the mapping table for flexible categorization** - ---- - -### 5.6 shop_drawing_main_categories (UPDATE v1.7.0) - -**Purpose**: Master table for shop drawing main categories (discipline-level) - -| Column Name | Data Type | Constraints | Description | -| ------------------ | ------------ | ----------------------------------- | ------------------------------------ | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique category ID | -| **project_id** | **INT** | **NOT NULL, FK** | **[NEW] Reference to projects** | -| main_category_code | VARCHAR(50) | NOT NULL, UNIQUE | Category code (ARCH, STR, MEP, etc.) | -| main_category_name | VARCHAR(255) | NOT NULL | Category name | -| description | TEXT | NULL | Category description | -| sort_order | INT | DEFAULT 0 | Display order | -| is_active | TINYINT(1) | DEFAULT 1 | Active status | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | - -**Indexes**: - -* PRIMARY KEY (id) -* **FOREIGN KEY (project_id) REFERENCES projects(id)** -* UNIQUE (main_category_code) -* INDEX (is_active) -* INDEX (sort_order) - -**Relationships**: - -* **Parent: projects** -* Referenced by: shop_drawings, asbuilt_drawings - -**Business Rules**: - -* **[CHANGED] Project-specific categories (was global)** -* Typically represents engineering disciplines - ---- - -### 5.7 shop_drawing_sub_categories (UPDATE v1.7.0) - -**Purpose**: Master table for shop drawing sub-categories (component-level) - -| Column Name | Data Type | Constraints | Description | -| ----------------- | ------------ | ----------------------------------- | ----------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique sub-category ID | -| **project_id** | **INT** | **NOT NULL, FK** | **[NEW] Reference to projects** | -| sub_category_code | VARCHAR(50) | NOT NULL, UNIQUE | Sub-category code (STR-COLUMN, ARCH-DOOR, etc.) | -| sub_category_name | VARCHAR(255) | NOT NULL | Sub-category name | -| description | TEXT | NULL | Sub-category description | -| sort_order | INT | DEFAULT 0 | Display order | -| is_active | TINYINT(1) | DEFAULT 1 | Active status | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | - -**Indexes**: - -* PRIMARY KEY (id) -* **FOREIGN KEY (project_id) REFERENCES projects(id)** -* UNIQUE (sub_category_code) -* INDEX (is_active) -* INDEX (sort_order) - -**Relationships**: - -* **Parent: projects** -* Referenced by: shop_drawings, asbuilt_drawings - -**Business Rules**: - -* **[CHANGED] Project-specific sub-categories (was global)** -* **[REMOVED] No longer hierarchical under main categories** -* Represents specific drawing types or components - ---- - -### 5.8 shop_drawings (UPDATE v1.7.0) - -**Purpose**: Master table for shop drawings (contractor-submitted) - -| Column Name | Data Type | Constraints | Description | -| ---------------- | ------------ | ----------------------------------- | -------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID | -| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | -| project_id | INT | NOT NULL, FK | Reference to projects | -| drawing_number | VARCHAR(100) | NOT NULL, UNIQUE | Shop drawing number | -| main_category_id | INT | NOT NULL, FK | Reference to main category | -| sub_category_id | INT | NOT NULL, FK | Reference to sub-category | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | -| updated_by | INT | NULL, FK | User who last updated | - -**Indexes**: - -* PRIMARY KEY (id) -* UNIQUE (drawing_number) -* UNIQUE INDEX idx_shop_drawings_uuid (uuid) -* FOREIGN KEY (project_id) REFERENCES projects(id) -* FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id) -* FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id) -* FOREIGN KEY (updated_by) REFERENCES users(user_id) -* INDEX (project_id) -* INDEX (main_category_id) -* INDEX (sub_category_id) -* INDEX (deleted_at) - -**Relationships**: - -* Parent: projects, shop_drawing_main_categories, shop_drawing_sub_categories, users -* Children: shop_drawing_revisions - -**Business Rules**: - -* Drawing numbers are globally unique across all projects -* Represents contractor shop drawings -* Can have multiple revisions -* Soft delete preserves history -* **[CHANGED] Title moved to shop_drawing_revisions table** - ---- - -### 5.9 shop_drawing_revisions (UPDATE v1.7.0) - -**Purpose**: Child table storing revision history of shop drawings (1:N) - -| Column Name | Data Type | Constraints | Description | -| ------------------------- | ---------------- | --------------------------- | ---------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | -| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | -| shop_drawing_id | INT | NOT NULL, FK | Master shop drawing ID | -| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | -| revision_label | VARCHAR(10) | NULL | Display revision (A, B, C...) | -| revision_date | DATE | NULL | Revision date | -| **title** | **VARCHAR(500)** | **NOT NULL** | **[NEW] Drawing title** | -| description | TEXT | NULL | Revision description/changes | -| **legacy_drawing_number** | **VARCHAR(100)** | **NULL** | **[NEW] Original/legacy drawing number** | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (shop_drawing_id) REFERENCES shop_drawings(id) ON DELETE CASCADE -* UNIQUE KEY (shop_drawing_id, revision_number) -* UNIQUE INDEX idx_shop_drawing_revisions_uuid (uuid) -* INDEX (revision_date) - -**Relationships**: - -* Parent: shop_drawings -* Referenced by: rfa_items, shop_drawing_revision_contract_refs, shop_drawing_revision_attachments, asbuilt_revision_shop_revisions_refs - -**Business Rules**: - -* Revision numbers are sequential starting from 0 -* Each revision can reference multiple contract drawings -* Each revision can have multiple file attachments -* Linked to RFAs for approval tracking -* **[NEW] Title stored at revision level for version-specific naming** -* **[NEW] legacy_drawing_number supports data migration from old systems** - ---- - -### 5.10 shop_drawing_revision_contract_refs - -**Purpose**: Junction table linking shop drawing revisions to referenced contract drawings (M:N) - -| Column Name | Data Type | Constraints | Description | -| ------------------------ | --------- | --------------- | ---------------------------------- | -| shop_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to shop drawing revision | -| contract_drawing_id | INT | PRIMARY KEY, FK | Reference to contract drawing | - -**Indexes**: - -* PRIMARY KEY (shop_drawing_revision_id, contract_drawing_id) -* FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (contract_drawing_id) REFERENCES contract_drawings(id) ON DELETE CASCADE -* INDEX (contract_drawing_id) - -**Relationships**: - -* Parent: shop_drawing_revisions, contract_drawings - -**Business Rules**: - -* Tracks which contract drawings each shop drawing revision is based on -* Ensures compliance with contract specifications -* One shop drawing revision can reference multiple contract drawings - ---- - -### 5.11 asbuilt_drawings (NEW v1.7.0) - -**Purpose**: Master table for AS Built drawings (final construction records) - -| Column Name | Data Type | Constraints | Description | -| ---------------- | ------------ | ----------------------------------- | -------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID | -| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | -| project_id | INT | NOT NULL, FK | Reference to projects | -| drawing_number | VARCHAR(100) | NOT NULL, UNIQUE | AS Built drawing number | -| main_category_id | INT | NOT NULL, FK | Reference to main category | -| sub_category_id | INT | NOT NULL, FK | Reference to sub-category | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | -| updated_by | INT | NULL, FK | User who last updated | - -**Indexes**: - -* PRIMARY KEY (id) -* UNIQUE (drawing_number) -* UNIQUE INDEX idx_asbuilt_drawings_uuid (uuid) -* FOREIGN KEY (project_id) REFERENCES projects(id) -* FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id) -* FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id) -* FOREIGN KEY (updated_by) REFERENCES users(user_id) -* INDEX (project_id) -* INDEX (main_category_id) -* INDEX (sub_category_id) -* INDEX (deleted_at) - -**Relationships**: - -* Parent: projects, shop_drawing_main_categories, shop_drawing_sub_categories, users -* Children: asbuilt_drawing_revisions - -**Business Rules**: - -* Drawing numbers are globally unique across all projects -* Represents final as-built construction drawings -* Can have multiple revisions -* Soft delete preserves history -* Uses same category structure as shop drawings - ---- - -### 5.12 asbuilt_drawing_revisions (NEW v1.7.0) - -**Purpose**: Child table storing revision history of AS Built drawings (1:N) - -| Column Name | Data Type | Constraints | Description | -| --------------------- | ------------ | --------------------------- | ------------------------------ | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | -| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | -| asbuilt_drawing_id | INT | NOT NULL, FK | Master AS Built drawing ID | -| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | -| revision_label | VARCHAR(10) | NULL | Display revision (A, B, C...) | -| revision_date | DATE | NULL | Revision date | -| title | VARCHAR(500) | NOT NULL | Drawing title | -| description | TEXT | NULL | Revision description/changes | -| legacy_drawing_number | VARCHAR(100) | NULL | Original/legacy drawing number | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (asbuilt_drawing_id) REFERENCES asbuilt_drawings(id) ON DELETE CASCADE -* UNIQUE KEY (asbuilt_drawing_id, revision_number) -* UNIQUE INDEX idx_asbuilt_drawing_revisions_uuid (uuid) -* INDEX (revision_date) - -**Relationships**: - -* Parent: asbuilt_drawings -* Referenced by: asbuilt_revision_shop_revisions_refs, asbuilt_drawing_revision_attachments - -**Business Rules**: - -* Revision numbers are sequential starting from 0 -* Each revision can reference multiple shop drawing revisions -* Each revision can have multiple file attachments -* Title stored at revision level for version-specific naming -* legacy_drawing_number supports data migration from old systems - ---- - -### 5.13 asbuilt_revision_shop_revisions_refs (NEW v1.7.0) - -**Purpose**: Junction table linking AS Built drawing revisions to shop drawing revisions (M:N) - -| Column Name | Data Type | Constraints | Description | -| --------------------------- | --------- | --------------- | ---------------------------------- | -| asbuilt_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to AS Built revision | -| shop_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to shop drawing revision | - -**Indexes**: - -* PRIMARY KEY (asbuilt_drawing_revision_id, shop_drawing_revision_id) -* FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE -* INDEX (shop_drawing_revision_id) - -**Relationships**: - -* Parent: asbuilt_drawing_revisions, shop_drawing_revisions - -**Business Rules**: - -* Tracks which shop drawings each AS Built drawing revision is based on -* Maintains construction document lineage -* One AS Built revision can reference multiple shop drawing revisions -* Supports traceability from final construction to approved shop drawings - ---- - -### 5.14 asbuilt_drawing_revision_attachments (NEW v1.7.0) - -**Purpose**: Junction table linking AS Built drawing revisions to file attachments (M:N) - -| Column Name | Data Type | Constraints | Description | -| --------------------------- | ------------------------------------- | --------------- | ------------------------------------- | -| asbuilt_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to AS Built revision | -| attachment_id | INT | PRIMARY KEY, FK | Reference to attachment file | -| file_type | ENUM('PDF', 'DWG', 'SOURCE', 'OTHER') | NULL | File type classification | -| is_main_document | BOOLEAN | DEFAULT FALSE | Main document flag (1 = primary file) | - -**Indexes**: - -* PRIMARY KEY (asbuilt_drawing_revision_id, attachment_id) -* FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE -* INDEX (attachment_id) - -**Relationships**: - -* Parent: asbuilt_drawing_revisions, attachments - -**Business Rules**: - -* Each AS Built revision can have multiple file attachments -* File types: PDF (documents), DWG (CAD files), SOURCE (source files), OTHER (miscellaneous) -* One attachment can be marked as main document per revision -* Cascade delete when revision is deleted - ---- - -## **6. 🔄 Circulations Tables (ใบเวียนภายใน)** - -### 6.1 circulation_status_codes - -**Purpose**: Master table for circulation workflow status codes - -| Column Name | Data Type | Constraints | Description | -| ----------- | ----------- | --------------------------- | --------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique status ID | -| code | VARCHAR(20) | NOT NULL, UNIQUE | Status code (OPEN, IN_REVIEW, COMPLETED, CANCELLED) | -| description | VARCHAR(50) | NOT NULL | Status description | -| sort_order | INT | DEFAULT 0 | Display order | -| is_active | TINYINT(1) | DEFAULT 1 | Active status | - -**Indexes**: - -* PRIMARY KEY (id) -* UNIQUE (code) -* INDEX (is_active) -* INDEX (sort_order) - -**Relationships**: - -* Referenced by: circulations - -**Seed Data**: 4 status codes - -* OPEN: Initial status when created -* IN_REVIEW: Under review by recipients -* COMPLETED: All recipients have responded -* CANCELLED: Withdrawn/cancelled - ---- - -### 6.2 circulations - -**Purpose**: Master table for internal circulation sheets (document routing) - -| Column Name | Data Type | Constraints | Description | -| ----------------------- | ------------ | ----------------------------------- | ----------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique circulation ID | -| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | -| correspondence_id | INT | UNIQUE, FK | Link to correspondence (1:1 relationship) | -| organization_id | INT | NOT NULL, FK | Organization that owns this circulation | -| circulation_no | VARCHAR(100) | NOT NULL | Circulation sheet number | -| circulation_subject | VARCHAR(500) | NOT NULL | Subject/title | -| circulation_status_code | VARCHAR(20) | NOT NULL, FK | Current status code | -| created_by_user_id | INT | NOT NULL, FK | User who created circulation | -| submitted_at | TIMESTAMP | NULL | Submission timestamp | -| closed_at | TIMESTAMP | NULL | Closure timestamp | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | - -**Indexes**: - -* PRIMARY KEY (id) -* UNIQUE (correspondence_id) -* FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) -* FOREIGN KEY (organization_id) REFERENCES organizations(id) -* FOREIGN KEY (circulation_status_code) REFERENCES circulation_status_codes(code) -* FOREIGN KEY (created_by_user_id) REFERENCES users(user_id) -* INDEX (organization_id) -* UNIQUE INDEX idx_circulations_uuid (uuid) -* INDEX (circulation_status_code) -* INDEX (created_by_user_id) - -**Relationships**: - -* Parent: correspondences, organizations, circulation_status_codes, users -* Children: circulation_routings, circulation_attachments - -**Business Rules**: - -* Internal document routing within organization -* One-to-one relationship with correspondences -* Tracks document review/approval workflow -* Status progression: OPEN → IN_REVIEW → COMPLETED/CANCELLED - ---- - -## **7. 📤 Transmittals Tables (เอกสารนำส่ง)** - -### 7.1 transmittals - -**Purpose**: Child table for transmittal-specific data (1:1 with correspondences) - -| Column Name | Data Type | Constraints | Description | -| ----------------- | --------- | --------------- | --------------------------------------------------------- | -| correspondence_id | INT | PRIMARY KEY, FK | Reference to correspondences (1:1) | -| purpose | ENUM | NULL | Purpose: FOR_APPROVAL, FOR_INFORMATION, FOR_REVIEW, OTHER | -| remarks | TEXT | NULL | Additional remarks | - -**Indexes**: - -* PRIMARY KEY (correspondence_id) -* FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* INDEX (purpose) - -**Relationships**: - -* Parent: correspondences -* Children: transmittal_items - -**Business Rules**: - -* One-to-one relationship with correspondences -* Transmittal is a correspondence type for forwarding documents -* Contains metadata about the transmission - ---- - -### 7.2 transmittal_items - -**Purpose**: Junction table listing documents included in transmittal (M:N) - -| Column Name | Data Type | Constraints | Description | -| ---------------------- | ------------ | --------------------------- | --------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique item ID | -| transmittal_id | INT | NOT NULL, FK | Reference to transmittal | -| item_correspondence_id | INT | NOT NULL, FK | Reference to document being transmitted | -| quantity | INT | DEFAULT 1 | Number of copies | -| remarks | VARCHAR(255) | NULL | Item-specific remarks | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (transmittal_id) REFERENCES transmittals(correspondence_id) ON DELETE CASCADE -* FOREIGN KEY (item_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* UNIQUE KEY (transmittal_id, item_correspondence_id) -* INDEX (item_correspondence_id) - -**Relationships**: - -* Parent: transmittals, correspondences - -**Business Rules**: - -* One transmittal can contain multiple documents -* Tracks quantity of physical copies (if applicable) -* Links to any type of correspondence document - ---- - -## **8. 📎 File Management Tables (ไฟล์แนบ)** - -### 8.1 attachments - -**Purpose**: Central repository for all file attachments in the system - -| Column Name | Data Type | Constraints | Description | -| ------------------- | ------------ | --------------------------- | ------------------------------------------------------------------------ | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique attachment ID | -| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | -| original_filename | VARCHAR(255) | NOT NULL | Original filename from upload | -| stored_filename | VARCHAR(255) | NOT NULL | System-generated unique filename | -| file_path | VARCHAR(500) | NOT NULL | Full file path on server (/share/dms-data/) | -| mime_type | VARCHAR(100) | NOT NULL | MIME type (application/pdf, image/jpeg, etc.) | -| file_size | INT | NOT NULL | File size in bytes | -| uploaded_by_user_id | INT | NOT NULL, FK | User who uploaded file | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Upload timestamp | -| is_temporary | BOOLEAN | DEFAULT TRUE | ระบุว่าเป็นไฟล์ชั่วคราว (ยังไม่ได้ Commit) | -| temp_id\* | VARCHAR(100) | NULL | ID ชั่วคราวสำหรับอ้างอิงตอน Upload Phase 1 (อาจใช้ร่วมกับ id หรือแยกก็ได้) | -| expires_at | DATETIME | NULL | เวลาหมดอายุของไฟล์ Temp (เพื่อให้ Cron Job ลบออก) | -| checksum | VARCHAR(64) | NULL | SHA-256 Checksum สำหรับ Verify File Integrity [Req 3.9.3] | -| reference_date | DATE | NULL | Date used for folder structure (e.g. Issue Date) to prevent broken paths | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (uploaded_by_user_id) REFERENCES users(user_id) ON DELETE CASCADE -* INDEX (stored_filename) -* INDEX (mime_type) -* INDEX (uploaded_by_user_id) -* UNIQUE INDEX idx_attachments_uuid (uuid) -* INDEX (created_at) -* INDEX (reference_date) - -**Relationships**: - -* Parent: users -* Referenced by: correspondence_attachments, circulation_attachments, shop_drawing_revision_attachments, contract_drawing_attachments - -**Business Rules**: - -* Central storage prevents file duplication -* Stored filename prevents naming conflicts -* File path points to QNAP NAS storage -* Original filename preserved for download -* One file record can be linked to multiple documents - ---- - -### 8.2 correspondence_attachments - -**Purpose**: Junction table linking correspondences to file attachments (M:N) - -| Column Name | Data Type | Constraints | Description | -| ----------------- | --------- | --------------- | ---------------------------- | -| correspondence_id | INT | PRIMARY KEY, FK | Reference to correspondences | -| attachment_id | INT | PRIMARY KEY, FK | Reference to attachments | -| is_main_document | BOOLEAN | DEFAULT FALSE | Main/primary document flag | - -**Indexes**: - -* PRIMARY KEY (correspondence_id, attachment_id) -* FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE -* INDEX (attachment_id) -* INDEX (is_main_document) - -**Relationships**: - -* Parent: correspondences, attachments - -**Business Rules**: - -* One correspondence can have multiple attachments -* One attachment can be linked to multiple correspondences -* is_main_document identifies primary file (typically PDF) - ---- - -### 8.3 circulation_attachments - -**Purpose**: Junction table linking circulations to file attachments (M:N) - -| Column Name | Data Type | Constraints | Description | -| ---------------- | --------- | --------------- | -------------------------- | -| circulation_id | INT | PRIMARY KEY, FK | Reference to circulations | -| attachment_id | INT | PRIMARY KEY, FK | Reference to attachments | -| is_main_document | BOOLEAN | DEFAULT FALSE | Main/primary document flag | - -**Indexes**: - -* PRIMARY KEY (circulation_id, attachment_id) -* FOREIGN KEY (circulation_id) REFERENCES circulations(id) ON DELETE CASCADE -* FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE -* INDEX (attachment_id) -* INDEX (is_main_document) - -**Relationships**: - -* Parent: circulations, attachments - ---- - -### 8.4 shop_drawing_revision_attachments - -**Purpose**: Junction table linking shop drawing revisions to file attachments (M:N) - -| Column Name | Data Type | Constraints | Description | -| ------------------------ | --------- | --------------- | ---------------------------------- | -| shop_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to shop drawing revision | -| attachment_id | INT | PRIMARY KEY, FK | Reference to attachments | -| file_type | ENUM | NULL | File type: PDF, DWG, SOURCE, OTHER | -| is_main_document | BOOLEAN | DEFAULT FALSE | Main/primary document flag | - -**Indexes**: - -* PRIMARY KEY (shop_drawing_revision_id, attachment_id) -* FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE -* INDEX (attachment_id) -* INDEX (file_type) -* INDEX (is_main_document) - -**Relationships**: - -* Parent: shop_drawing_revisions, attachments - -**Business Rules**: - -* file_type categorizes drawing file formats -* Typically includes PDF for viewing and DWG for editing -* SOURCE may include native CAD files - ---- - -### 8.5 contract_drawing_attachments - -**Purpose**: Junction table linking contract drawings to file attachments (M:N) - -| Column Name | Data Type | Constraints | Description | -| ------------------- | --------- | --------------- | ---------------------------------- | -| contract_drawing_id | INT | PRIMARY KEY, FK | Reference to contract drawing | -| attachment_id | INT | PRIMARY KEY, FK | Reference to attachments | -| file_type | ENUM | NULL | File type: PDF, DWG, SOURCE, OTHER | -| is_main_document | BOOLEAN | DEFAULT FALSE | Main/primary document flag | - -**Indexes**: - -* PRIMARY KEY (contract_drawing_id, attachment_id) -* FOREIGN KEY (contract_drawing_id) REFERENCES contract_drawings(id) ON DELETE CASCADE -* FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE -* INDEX (attachment_id) -* INDEX (file_type) -* INDEX (is_main_document) - -**Relationships**: - -* Parent: contract_drawings, attachments - ---- - -## **9. 🔢 Document Numbering System Tables (ระบบเลขที่เอกสาร)** - -### 9.1 document_number_formats - -**Purpose**: Master table defining numbering formats for each document type - -| Column Name | Data Type | Constraints | Description | -| ---------------------- | ------------ | --------------------------- | -------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique format ID | -| project_id | INT | NOT NULL, FK | Reference to projects | -| correspondence_type_id | INT | NULL, FK | Reference to correspondence_types | -| discipline_id | INT | DEFAULT 0, FK | Reference to disciplines (0 = all) | -| format_string | VARCHAR(100) | NOT NULL | Format pattern (e.g., {ORG}-{TYPE}-{YYYY}-#) | -| description | TEXT | NULL | Format description | -| reset_annually | BOOLEAN | DEFAULT TRUE | Start sequence new every year | -| is_active | TINYINT(1) | DEFAULT 1 | Active status | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE -* UNIQUE KEY (project_id, correspondence_type_id, discipline_id) -* INDEX (is_active) - -**Relationships**: - -* Parent: projects, correspondence_types - -**Business Rules**: - -* Defines how document numbers are constructed -* Supports placeholders: {PROJ}, {ORG}, {TYPE}, {YYYY}, {MM}, {#} - ---- - -### 9.2 document_number_counters (UPDATE v1.7.0) - -**Purpose**: Transaction table tracking running numbers (High Concurrency) - -| Column Name | Data Type | Constraints | Description | -| -------------------------- | ----------- | ------------- | ----------------------------------------------- | -| project_id | INT | PK, NOT NULL | โครงการ | -| originator_organization_id | INT | PK, NOT NULL | องค์กรผู้ส่ง | -| recipient_organization_id | INT | PK, NOT NULL | องค์กรผู้รับ (0 = no recipient / RFA) | -| correspondence_type_id | INT | PK, NULL | ประเภทเอกสาร (NULL = default) | -| sub_type_id | INT | PK, DEFAULT 0 | ประเภทย่อย สำหรับ TRANSMITTAL (0 = ไม่ระบุ) | -| rfa_type_id | INT | PK, DEFAULT 0 | ประเภท RFA (0 = ไม่ใช่ RFA) | -| discipline_id | INT | PK, DEFAULT 0 | สาขางาน (0 = ไม่ระบุ) | -| reset_scope | VARCHAR(20) | PK, NOT NULL | Scope of reset (YEAR_2024, MONTH_2024_01, NONE) | -| last_number | INT | DEFAULT 0 | เลขล่าสุดที่ถูกใช้งานไปแล้ว | -| version | INT | DEFAULT 0 | Optimistic Lock Version | -| updated_at | DATETIME(6) | ON UPDATE | เวลาที่อัปเดตล่าสุด | - -**Indexes**: - -* **PRIMARY KEY (project_id, originator_organization_id, recipient_organization_id, correspondence_type_id, sub_type_id, rfa_type_id, discipline_id, reset_scope)** -* INDEX idx_counter_lookup (project_id, correspondence_type_id, reset_scope) -* INDEX idx_counter_org (originator_organization_id, reset_scope) - -**Business Rules**: - -* **Composite Primary Key 8 Columns**: เพื่อรองรับการรันเลขที่ซับซ้อนและ Reset Scope ที่หลากหลาย -* **Concurrency Control**: ใช้ Redis Lock หรือ Optimistic Locking (version) -* **Reset Scope**: ใช้ Field `reset_scope` ควบคุมการ Reset แทน `current_year` แบบเดิม - ---- - -### 9.3 document_number_audit (UPDATE v1.7.0) - -**Purpose**: Audit log for document number generation (Debugging & Tracking) - -| Column Name | Data Type | Constraints | Description | -| :------------------------- | :----------- | :----------------- | :-------------------------------------- | -| id | INT | PK, AI | ID ของ audit record | -| document_id | INT | NULL, FK | ID ของเอกสารที่สร้างเลขที่ (NULL if failed) | -| document_type | VARCHAR(50) | NULL | ประเภทเอกสาร | -| document_number | VARCHAR(100) | NOT NULL | เลขที่เอกสารที่สร้าง (ผลลัพธ์) | -| operation | ENUM | DEFAULT 'CONFIRM' | RESERVE, CONFIRM, MANUAL_OVERRIDE, etc. | -| status | ENUM | DEFAULT 'RESERVED' | RESERVED, CONFIRMED, CANCELLED, VOID | -| counter_key | JSON | NOT NULL | Counter key ที่ใช้ (JSON 8 fields) | -| reservation_token | VARCHAR(36) | NULL | Token การจอง | -| idempotency_key | VARCHAR(128) | NULL | Idempotency Key from request | -| originator_organization_id | INT | NULL | องค์กรผู้ส่ง | -| recipient_organization_id | INT | NULL | องค์กรผู้รับ | -| template_used | VARCHAR(200) | NOT NULL | Template ที่ใช้ในการสร้าง | -| old_value | TEXT | NULL | Previous value | -| new_value | TEXT | NULL | New value | -| user_id | INT | NULL, FK | ผู้ขอสร้างเลขที่ | -| is_success | BOOLEAN | DEFAULT TRUE | สถานะความสำเร็จ | -| created_at | TIMESTAMP | DEFAULT NOW | วันที่/เวลาที่สร้าง | -| total_duration_ms | INT | NULL | เวลารวมทั้งหมดในการสร้าง (ms) | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (document_id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (user_id) REFERENCES users(user_id) -* INDEX (document_id) -* INDEX (user_id) -* INDEX (status) -* INDEX (operation) -* INDEX (document_number) -* INDEX (reservation_token) -* INDEX (created_at) - ---- - -### 9.4 document_number_errors (UPDATE v1.7.0) - -**Purpose**: Error log for failed document number generation - -| Column Name | Data Type | Constraints | Description | -| :------------ | :-------- | :---------- | :--------------------------------------------- | -| id | INT | PK, AI | ID ของ error record | -| error_type | ENUM | NOT NULL | LOCK_TIMEOUT, VERSION_CONFLICT, DB_ERROR, etc. | -| error_message | TEXT | NULL | ข้อความ error | -| stack_trace | TEXT | NULL | Stack trace สำหรับ debugging | -| context_data | JSON | NULL | Context ของ request | -| user_id | INT | NULL | ผู้ที่เกิด error | -| created_at | TIMESTAMP | DEFAULT NOW | วันที่เกิด error | -| resolved_at | TIMESTAMP | NULL | วันที่แก้ไขแล้ว | - -**Indexes**: - -* PRIMARY KEY (id) -* INDEX (error_type) -* INDEX (created_at) -* INDEX (user_id) -* INDEX (resolved_at) - ---- - -### 9.5 document_number_reservations (NEW v1.7.0) - -**Purpose**: Two-Phase Commit table for document number reservation - -| Column Name | Data Type | Constraints | Description | -| :--------------------- | :----------- | :--------------- | :----------------------------------- | -| id | INT | PK, AI | Unique ID | -| token | VARCHAR(36) | UNIQUE, NOT NULL | UUID v4 Reservation Token | -| document_number | VARCHAR(100) | UNIQUE, NOT NULL | เลขที่เอกสารที่จอง | -| document_number_status | ENUM | DEFAULT RESERVED | RESERVED, CONFIRMED, CANCELLED, VOID | -| document_id | INT | NULL, FK | ID ของเอกสาร (เมื่อ Confirm แล้ว) | -| expires_at | DATETIME(6) | NOT NULL | เวลาหมดอายุการจอง | -| project_id | INT | NOT NULL, FK | Project Context | -| user_id | INT | NOT NULL, FK | User Context | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (document_id) REFERENCES correspondence_revisions(id) ON DELETE SET NULL -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE -* FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE -* INDEX idx_token (token) -* INDEX idx_status (document_number_status) -* INDEX idx_status_expires (document_number_status, expires_at) -* INDEX idx_document_id (document_id) -* INDEX idx_user_id (user_id) -* INDEX idx_reserved_at (reserved_at) - ---- - -## **10. ⚙️ Unified Workflow Engine Tables (UPDATE v1.7.0)** - -### 10.1 workflow_definitions - -**Purpose**: เก็บแม่แบบ (Template) ของ Workflow (Definition / DSL) - -| Column Name | Data Type | Constraints | Description | -| :------------ | :---------- | :----------- | :------------------------------------- | -| id | CHAR(36) | PK, UUID | Unique Workflow Definition ID | -| workflow_code | VARCHAR(50) | NOT NULL | รหัส Workflow (เช่น RFA_FLOW_V1) | -| version | INT | DEFAULT 1 | หมายเลข Version | -| description | TEXT | NULL | คำอธิบาย Workflow | -| dsl | JSON | NOT NULL | นิยาม Workflow ต้นฉบับ (YAML/JSON Format) | -| compiled | JSON | NOT NULL | โครงสร้าง Execution Tree ที่ Compile แล้ว | -| is_active | BOOLEAN | DEFAULT TRUE | สถานะการใช้งาน | -| created_at | TIMESTAMP | DEFAULT NOW | วันที่สร้าง | -| updated_at | TIMESTAMP | ON UPDATE | วันที่แก้ไขล่าสุด | - -**Indexes**: - -* PRIMARY KEY (id) -* UNIQUE KEY (workflow_code, version) -* INDEX (is_active) - ---- - -### 10.2 workflow_instances - -**Purpose**: เก็บสถานะของ Workflow ที่กำลังรันอยู่จริง (Runtime) - -| Column Name | Data Type | Constraints | Description | -| :------------ | :---------- | :--------------- | :--------------------------------------------- | -| id | CHAR(36) | PK, UUID | Unique Instance ID | -| definition_id | CHAR(36) | FK, NOT NULL | อ้างอิง Definition ที่ใช้ | -| entity_type | VARCHAR(50) | NOT NULL | ประเภทเอกสาร (rfa_revision, correspondence...) | -| entity_id | VARCHAR(50) | NOT NULL | ID ของเอกสาร | -| current_state | VARCHAR(50) | NOT NULL | สถานะปัจจุบัน | -| status | ENUM | DEFAULT 'ACTIVE' | ACTIVE, COMPLETED, CANCELLED, TERMINATED | -| context | JSON | NULL | ตัวแปร Context สำหรับตัดสินใจ | -| created_at | TIMESTAMP | DEFAULT NOW | เวลาที่สร้าง | -| updated_at | TIMESTAMP | ON UPDATE | เวลาที่อัปเดตล่าสุด | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (definition_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE -* INDEX (entity_type, entity_id) -* INDEX (current_state) - ---- - -### 10.3 workflow_histories - -**Purpose**: เก็บประวัติการดำเนินการในแต่ละ Step (Audit Trail) - -| Column Name | Data Type | Constraints | Description | -| :---------------- | :---------- | :----------- | :-------------------- | -| id | CHAR(36) | PK, UUID | Unique ID | -| instance_id | CHAR(36) | FK, NOT NULL | อ้างอิง Instance | -| from_state | VARCHAR(50) | NOT NULL | สถานะต้นทาง | -| to_state | VARCHAR(50) | NOT NULL | สถานะปลายทาง | -| action | VARCHAR(50) | NOT NULL | Action ที่กระทำ | -| action_by_user_id | INT | FK, NULL | User ID ผู้กระทำ | -| comment | TEXT | NULL | ความเห็น | -| metadata | JSON | NULL | Snapshot ข้อมูล ณ ขณะนั้น | -| created_at | TIMESTAMP | DEFAULT NOW | เวลาที่กระทำ | - -**Indexes**: - -* PRIMARY KEY (id) -* FOREIGN KEY (instance_id) REFERENCES workflow_instances(id) ON DELETE CASCADE -* INDEX (instance_id) -* INDEX (action_by_user_id) - ---- - -## **11. 🖥️ System & Logs Tables (ระบบ, บันทึก)** - -> **Audit Logging Architecture:** -### 1. Audit Logging - -**Table: `audit_logs`** - -บันทึกการเปลี่ยนแปลงสำคัญ: - -- User actions (CREATE, UPDATE, DELETE) -- Entity type และ Entity ID -- Old/New values (JSON) -- IP Address, User Agent - -### 2. User Preferences - -**Table: `user_preferences`** - -เก็บการตั้งค่าส่วนตัว: - -- Language preference -- Notification settings -- UI preferences (JSON) - -### 3. JSON Schema Validation - -**Table: `json_schemas`** - -เก็บ Schema สำหรับ Validate JSON fields: - -- `correspondence_revisions.details` -- `user_preferences.preferences` - ---- - - -### 11.1 json_schemas (UPDATE v1.7.0) - -**Purpose**: เก็บ Schema สำหรับ Validate JSON Columns (Req 3.12) - -| Column Name | Data Type | Constraints | Description | -| :---------------- | :----------- | :----------- | :------------------------------- | -| id | INT | PK, AI | Unique ID | -| schema_code | VARCHAR(100) | NOT NULL | รหัส Schema (เช่น RFA_DWG) | -| version | INT | DEFAULT 1 | เวอร์ชันของ Schema | -| table_name | VARCHAR(100) | NOT NULL | ชื่อตารางเป้าหมาย | -| schema_definition | JSON | NOT NULL | JSON Schema Definition | -| ui_schema | JSON | NULL | โครงสร้าง UI Schema สำหรับ Frontend | -| virtual_columns | JSON | NULL | Config สำหรับสร้าง Virtual Columns | -| migration_script | JSON | NULL | Script สำหรับแปลงข้อมูล | -| is_active | BOOLEAN | DEFAULT TRUE | สถานะการใช้งาน | - - ---- - -### 11.2 audit_logs (UPDATE v1.7.0) - -**Purpose**: Centralized audit logging for all system actions (Req 6.1) - -| Column Name | Data Type | Constraints | Description | -| :----------- | :----------- | :------------------------ | :------------------------------------------- | -| audit_id | BIGINT | PK, AI | Unique log ID | -| request_id | VARCHAR(100) | NULL | Trace ID linking to app logs | -| user_id | INT | NULL, FK | User who performed action | -| action | VARCHAR(100) | NOT NULL | Action name (e.g. rfa.create) | -| severity | ENUM | DEFAULT 'INFO' | INFO, WARN, ERROR, CRITICAL | -| entity_type | VARCHAR(50) | NULL | Module/Table name (e.g. rfa, correspondence) | -| entity_id | VARCHAR(50) | NULL | ID of affected entity | -| details_json | JSON | NULL | Context data / Old & New values | -| ip_address | VARCHAR(45) | NULL | User IP address | -| user_agent | VARCHAR(255) | NULL | User browser/client info | -| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Log timestamp | - -**Indexes**: - -* PRIMARY KEY (audit_id, created_at) -- **Partition Key** -* INDEX idx_audit_user (user_id) -* INDEX idx_audit_action (action) -* INDEX idx_audit_entity (entity_type, entity_id) -* INDEX idx_audit_created (created_at) - -**Partitioning**: -* **PARTITION BY RANGE (YEAR(created_at))**: แบ่ง Partition รายปี เพื่อประสิทธิภาพในการเก็บข้อมูลระยะยาว - ---- - -### 11.3 notifications (UPDATE v1.7.0) - -**Purpose**: System notifications for users - -| Column Name | Data Type | Constraints | Description | -| :---------------- | :----------- | :-------------------------- | :------------------------ | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique notification ID | -| uuid | UUID | NOT NULL, DEFAULT | UUID Public Identifier (ADR-019) | -| user_id | INT | NOT NULL, FK | Recipient user ID | -| title | VARCHAR(255) | NOT NULL | Notification title | -| message | TEXT | NOT NULL | Notification body | -| notification_type | ENUM | NOT NULL | Type: EMAIL, LINE, SYSTEM | -| is_read | BOOLEAN | DEFAULT FALSE | Read status | -| entity_type | VARCHAR(50) | NULL | Related Entity Type | -| entity_id | INT | NULL | Related Entity ID | -| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Notification timestamp | - -**Indexes**: - -* PRIMARY KEY (id, created_at) -- **Partition Key** -* INDEX idx_notif_user (user_id) -* INDEX idx_notif_type (notification_type) -* INDEX idx_notif_read (is_read) -* INDEX idx_notif_created (created_at) -* INDEX idx_notifications_uuid (uuid) - -**Partitioning**: -* **PARTITION BY RANGE (YEAR(created_at))**: แบ่ง Partition รายปี - ---- - -## **12. 🔍 Views (มุมมองข้อมูล)** - -### 12.1 v_current_correspondences - -**Purpose**: แสดงข้อมูล Correspondence Revision ล่าสุด (is_current = TRUE) - -### 12.2 v_current_rfas - -**Purpose**: แสดงข้อมูล RFA Revision ล่าสุด พร้อม Status และ Approve Code - -### 12.3 v_user_tasks (Unified Workflow) - -**Purpose**: รวมรายการงานที่ยังค้างอยู่ (Status = ACTIVE) จากทุกระบบ (RFA, Circulation, Correspondence) เพื่อนำไปแสดงใน Dashboard - -### 12.4 v_audit_log_details - -**Purpose**: แสดง audit_logs พร้อมข้อมูล username และ email ของผู้กระทำ - -### 12.5 v_user_all_permissions - -**Purpose**: รวมสิทธิ์ทั้งหมด (Global + Project + Organization) ของผู้ใช้ทุกคน - -### 12.6 v_documents_with_attachments - -**Purpose**: แสดงเอกสารทั้งหมดที่มีไฟล์แนบ (Correspondence, Circulation, Drawings) - -### 12.7 v_document_statistics - -**Purpose**: แสดงสถิติเอกสารตามประเภทและสถานะ - ---- - -## **13. 📊 Index Summaries (สรุป Index)** - -> **Performance Optimization Strategy:** -### 1. Indexing Strategy - -**Primary Indexes:** - -- Primary Keys (AUTO_INCREMENT) -- Foreign Keys (automatic in InnoDB) -- Unique Constraints (business keys) - -**Secondary Indexes:** - -```sql --- Correspondence search -CREATE INDEX idx_corr_type_status ON correspondence_revisions(correspondence_type_id, correspondence_status_id); -CREATE INDEX idx_corr_date ON correspondence_revisions(document_date); - --- Virtual columns for JSON -CREATE INDEX idx_v_ref_project ON correspondence_revisions(v_ref_project_id); -CREATE INDEX idx_v_doc_subtype ON correspondence_revisions(v_doc_subtype); - --- User lookup -CREATE INDEX idx_user_email ON users(email); -CREATE INDEX idx_user_org ON users(primary_organization_id, is_active); -``` - -### 2. Virtual Columns - -ใช้ Virtual Columns สำหรับ Index JSON fields: - -```sql -ALTER TABLE correspondence_revisions -ADD COLUMN v_ref_project_id INT GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(details, '$.ref_project_id'))) VIRTUAL, -ADD INDEX idx_v_ref_project(v_ref_project_id); -``` - -### 3. Partitioning (Future) - -พิจารณา Partition ตาราง `audit_logs` ตามปี: - -```sql -ALTER TABLE audit_logs -PARTITION BY RANGE (YEAR(created_at)) ( - PARTITION p2024 VALUES LESS THAN (2025), - PARTITION p2025 VALUES LESS THAN (2026), - PARTITION p_future VALUES LESS THAN MAXVALUE -); -``` - ---- - - -### 13.1 Performance Indexes - -| Table Name | Index Columns | Purpose | -| :----------------------- | :------------------------------------------------ | :----------------------------- | -| correspondences | (project_id, correspondence_number) | Fast lookup by document number | -| correspondences | (correspondence_type_id) | Filter by type | -| correspondence_revisions | (correspondence_id, is_current) | Get current revision | -| rfas | (rfa_type_id) | Filter by RFA type | -| rfa_revisions | (rfa_id, is_current) | Get current RFA revision | -| rfa_revisions | (rfa_status_code_id) | Filter by status | -| audit_logs | (created_at) | Date range queries | -| audit_logs | (user_id) | User activity history | -| audit_logs | (module, action) | Action type analysis | -| notifications | (user_id, is_read) | Unread notifications query | -| document_number_counters | (project_id, correspondence_type_id, reset_scope) | Running number generation | -| workflow_instances | (entity_type, entity_id) | Workflow lookup by document ID | -| workflow_instances | (current_state) | Monitor active workflows | - -### 13.2 Unique Constraints - -| Table Name | Columns | Description | -| :---------------------- | :----------------------------------- | :--------------------------------- | -| users | (username) | Unique login name | -| users | (email) | Unique email address | -| organizations | (organization_code) | Unique organization code | -| projects | (project_code) | Unique project code | -| contracts | (contract_code) | Unique contract code | -| correspondences | (project_id, correspondence_number) | Unique document number per project | -| shop_drawings | (drawing_number) | Unique shop drawing number | -| document_number_formats | (project_id, correspondence_type_id) | One format per type per project | -| workflow_definitions | (workflow_code, version) | Unique workflow code per version | - ---- - -## **14. 🛡️ Data Integrity Constraints (ความถูกต้องของข้อมูล)** - -### 14.1 Soft Delete Policy - -* **Tables with `deleted_at`**: - * users - * organizations - * projects - * contracts - * correspondences - * rfas - * shop_drawings - * contract_drawings -* **Rule**: Records are never physically deleted. `deleted_at` is set to timestamp. -* **Query Rule**: All standard queries MUST include `WHERE deleted_at IS NULL`. - -### 14.2 Foreign Key Cascades - -* **ON DELETE CASCADE**: - * Used for child tables that cannot exist without parent (e.g., `correspondence_revisions`, `rfa_revisions`, `correspondence_attachments`). -* **ON DELETE RESTRICT**: - * Used for master data references to prevent accidental deletion of used data (e.g., `correspondence_types`, `organizations`). -* **ON DELETE SET NULL**: - * Used for optional references (e.g., `created_by`, `originator_id`). - ---- - -## **15. 🔐 Security & Permissions Model (ความปลอดภัย)** - -### 15.1 Row-Level Security (RLS) Logic - -* **Organization Scope**: Users can only see documents where `originator_id` OR `recipient_organization_id` matches their organization. -* **Project Scope**: Users can only see documents within projects they are assigned to. -* **Confidentiality**: Documents marked `is_confidential` are visible ONLY to specific roles or users. - -### 15.2 Role-Based Access Control (RBAC) - -* **Permissions** are granular (e.g., `correspondence.view`, `correspondence.create`). -* **Roles** aggregate permissions (e.g., `Document Controller` = `view` + `create` + `edit`). -* **Assignments** link Users to Roles within a Context (Global, Project, or Organization). - ---- - -## **16. 🔄 Data Migration & Seeding (การย้ายข้อมูล)** - -### 16.1 Initial Seeding (V1.7.0) - -1. **Master Data**: - * `organizations`: Owner, Consultant, Contractor - * `projects`: LCBP3 - * `correspondence_types`: LETTER, MEMO, TRANSMITTAL, RFA - * `rfa_types`: DWG, MAT, DOC, RFI - * `rfa_status_codes`: DFT, PEND, APPR, REJ - * `disciplines`: GEN, STR, ARC, MEP -2. **System Users**: - * `admin`: Super Admin - * `system`: System Bot for automated tasks - -### 16.2 Migration Strategy - -* **Schema Migration**: Use TypeORM Migrations or raw SQL scripts (versioned). -* **Data Migration**: - * **V1.6.0 -> V1.7.0**: - * Run SQL script `9_lcbp3_v1_7_0.sql` - * Migrate `document_number_counters` to 8-col composite PK. - * Initialize `document_number_reservations`. - * Update `json_schemas` with new columns. - ---- - - -### 16.3 Temporary Migration Tracking Tables (V1.8.0 n8n Migration) - -ตารางเหล่านี้ถูกใช้ชั่วคราวระหว่างกระบวนการ Migrate เอกสาร PDF 20,000 ฉบับด้วย n8n (ดูรายละเอียดใน 3-05-n8n-migration-setup-guide.md) และไม่ใช่ตาราง Business หลักของระบบ - -#### 16.3.1 migration_progress -**Purpose**: เก็บ Checkpoint สถานะการ Migrate - -| Column Name | Data Type | Constraints | Description | -| :------------------- | :---------- | :---------------------------------- | :--------------------------------- | -| batch_id | VARCHAR(50) | PRIMARY KEY | รหัสชุดการ Migrate | -| last_processed_index | INT | DEFAULT 0 | ลำดับล่าสุดที่ประมวลผลผ่าน | -| status | ENUM | DEFAULT 'RUNNING' | สถานะ (RUNNING, COMPLETED, FAILED) | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | เวลาอัปเดตล่าสุด | - -#### 16.3.2 migration_review_queue -**Purpose**: คิวเอกสารที่ต้องการให้เจ้าหน้าที่ตรวจสอบ (Confidence ต่ำกว่าเกณฑ์) -*หมายเหตุ: เมื่อตรวจสอบผ่านและสร้าง Correspondence จริงแล้ว ข้อมูลในนี้อาจถูกลบหรือเก็บเป็น Log ได้* - -| Column Name | Data Type | Constraints | Description | -| :-------------------- | :----------- | :-------------------------- | :---------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique ID | -| document_number | VARCHAR(100) | NOT NULL, UNIQUE | เลขที่เอกสาร (จาก OCR) | -| title | TEXT | | ชื่อเรื่อง | -| original_title | TEXT | | ชื่อเรื่องต้นฉบับก่อนตรวจสอบ | -| ai_suggested_category | VARCHAR(50) | | หมวดหมู่ที่ AI แนะนำ | -| ai_confidence | DECIMAL(4,3) | | ค่าความมั่นใจของ AI (0.000 - 1.000) | -| ai_issues | JSON | | รายละเอียดปัญหาที่ AI พบ | -| review_reason | VARCHAR(255) | | เหตุผลที่ต้องตรวจสอบ (เช่น Confidence ต่ำ) | -| status | ENUM | DEFAULT 'PENDING' | PENDING, APPROVED, REJECTED | -| reviewed_by | VARCHAR(100) | | ผู้ตรวจสอบ | -| reviewed_at | TIMESTAMP | NULL | เวลาที่ตรวจสอบ | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | วันที่บันทึกเข้าคิว | - -#### 16.3.3 migration_errors -**Purpose**: บันทึกข้อผิดพลาด (Errors) ระหว่างการทำงานของ n8n workflow - -| Column Name | Data Type | Constraints | Description | -| :-------------- | :----------- | :-------------------------- | :-------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique ID | -| batch_id | VARCHAR(50) | INDEX | รหัสชุดการ Migrate | -| document_number | VARCHAR(100) | | เลขที่เอกสาร | -| error_type | ENUM | INDEX | ประเภท Error (FILE_NOT_FOUND, AI_PARSE_ERROR, etc.) | -| error_message | TEXT | | รายละเอียด Error | -| raw_ai_response | TEXT | | Raw response จาก AI กรณีแปลผลไม่ได้ | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | วันที่บันทึก | - -#### 16.3.4 migration_fallback_state -**Purpose**: ติดตามสถานะ Fallback ของ AI (เช่น เปลี่ยน Model เมื่อ Error ถี่) - -| Column Name | Data Type | Constraints | Description | -| :----------------- | :---------- | :---------------------------------- | :--------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique ID | -| batch_id | VARCHAR(50) | UNIQUE | รหัสชุดการ Migrate | -| recent_error_count | INT | DEFAULT 0 | จำนวน Error รวดล่าสุด | -| is_fallback_active | BOOLEAN | DEFAULT FALSE | สถานะการใช้งาน Fallback Model | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | เวลาอัปเดตล่าสุด | - -#### 16.3.5 import_transactions -**Purpose**: ป้องกันข้อมูลซ้ำ (Idempotency) ระหว่างการ Patch ข้อมูล - -| Column Name | Data Type | Constraints | Description | -| :-------------- | :----------- | :-------------------------- | :------------------------ | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique ID | -| idempotency_key | VARCHAR(255) | UNIQUE, NOT NULL | Key สำหรับเช็คซ้ำ | -| document_number | VARCHAR(100) | | เลขที่เอกสาร | -| batch_id | VARCHAR(100) | | รหัสชุดการ Migrate | -| status_code | INT | DEFAULT 201 | HTTP Status ของการ Import | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | วันที่บันทึก | - -#### 16.3.6 migration_daily_summary -**Purpose**: สรุปยอดการทำงานรายวันแยกตาม Batch - -| Column Name | Data Type | Constraints | Description | -| :-------------- | :---------- | :-------------------------- | :---------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique ID | -| batch_id | VARCHAR(50) | UNIQUE KEY PART 1 | รหัสชุดการ Migrate | -| summary_date | DATE | UNIQUE KEY PART 2 | วันที่สรุป | -| total_processed | INT | DEFAULT 0 | จำนวนที่ประมวลผลรวม | -| auto_ingested | INT | DEFAULT 0 | จำนวนที่เข้าสู่ระบบสำเร็จ | -| sent_to_review | INT | DEFAULT 0 | จำนวนที่ส่งคิวตรวจสอบ | -| rejected | INT | DEFAULT 0 | จำนวนที่ถูกปฏิเสธ | -| errors | INT | DEFAULT 0 | จำนวนที่เกิด Error | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | วันที่บันทึก | - ---- -## **17. 📈 Monitoring & Maintenance (การดูแลรักษา)** - -### 17.1 Database Maintenance - -* **Daily**: Incremental Backup. -* **Weekly**: Full Backup + `OPTIMIZE TABLE` for heavy tables (`audit_logs`, `notifications`). -* **Monthly**: Archive old `audit_logs` partitions to cold storage. - -### 17.2 Health Checks - -* Monitor `document_number_errors` for numbering failures. -* Monitor `workflow_instances` for stuck workflows (`status = ' IN_PROGRESS '` > 7 days). -* Check `document_number_counters` for gaps or resets. - ---- - -## **18. 📚 Best Practices** -### 1. Naming Conventions - -- **Tables**: `snake_case`, plural (e.g., `correspondences`, `users`) -- **Columns**: `snake_case` (e.g., `correspondence_number`, `created_at`) -- **Foreign Keys**: `{referenced_table_singular}_id` (e.g., `project_id`, `user_id`) -- **Junction Tables**: `{table1}_{table2}` (e.g., `correspondence_tags`) - -### 2. Timestamp Columns - -ทุกตารางควรมี: - -- `created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` -- `updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` - -### 3. Soft Delete - -ใช้ `deleted_at DATETIME NULL` แทนการลบจริง: - -```sql --- Soft delete -UPDATE correspondences SET deleted_at = NOW() WHERE id = 1; - --- Query active records -SELECT * FROM correspondences WHERE deleted_at IS NULL; -``` - -### 4. JSON Field Guidelines - -- ใช้สำหรับข้อมูลที่ไม่ต้อง Query บ่อย -- สร้าง Virtual Columns สำหรับ fields ที่ต้อง Index -- Validate ด้วย JSON Schema -- Document structure ใน Data Dictionary - ---- - ---- - -## **19. 📖 Glossary (คำศัพท์)** - -* **RFA**: Request for Approval (เอกสารขออนุมัติ) -* **Transmittal**: Document Transmittal Sheet (ใบนำส่งเอกสาร) -* **Shop Drawing**: แบบก่อสร้างที่ผู้รับเหมาจัดทำ -* **Contract Drawing**: แบบสัญญา (แบบตั้งต้น) -* **Revision**: ฉบับแก้ไข (0, 1, 2, A, B, C) -* **Originator**: ผู้จัดทำ/ผู้ส่งเอกสาร -* **Recipient**: ผู้รับเอกสาร -* **Workflow**: กระบวนการทำงาน/อนุมัติ -* **Discipline**: สาขางาน (เช่น โยธา, สถาปัตย์, ไฟฟ้า) - ---- - -**End of Data Dictionary V1.8.0** - +--- +title: 'Data & Storage: Data Dictionary and Data Model Architecture' +version: 1.8.0 +status: released +owner: Nattanin Peancharoen +last_updated: 2026-02-28 +related: + - specs/01-requirements/02-architecture.md + - specs/01-requirements/03-functional-requirements.md +--- + +# 1. Data Model Architecture Overview + +## 📋 1.1 Overview + +เอกสารนี้อธิบายสถาปัตยกรรมของ Data Model สำหรับระบบ LCBP3-DMS โดยครอบคลุมโครงสร้างฐานข้อมูล, ความสัมพันธ์ระหว่างตาราง, และหลักการออกแบบที่สำคัญ + +## 🎯 1.2 Design Principles + +### 1. Separation of Concerns + +- **Master-Revision Pattern**: แยกข้อมูลที่ไม่เปลี่ยนแปลง (Master) จากข้อมูลที่มีการแก้ไข (Revisions) + - `correspondences` (Master) ↔ `correspondence_revisions` (Revisions) + - `rfas` (Master) ↔ `rfa_revisions` (Revisions) + - `shop_drawings` (Master) ↔ `shop_drawing_revisions` (Revisions) + +### 2. Data Integrity + +- **Foreign Key Constraints**: ใช้ FK ทุกความสัมพันธ์เพื่อรักษาความสมบูรณ์ของข้อมูล +- **Soft Delete**: ใช้ `deleted_at` แทนการลบข้อมูลจริง เพื่อรักษาประวัติ +- **Optimistic Locking**: ใช้ `version` column ใน `document_number_counters` ป้องกัน Race Condition + +### 3. Flexibility & Extensibility + +- **JSON Details Field**: เก็บข้อมูลเฉพาะประเภทใน `correspondence_revisions.details` +- **Virtual Columns**: สร้าง Index จาก JSON fields สำหรับ Performance +- **Master Data Tables**: แยกข้อมูล Master (Types, Status, Codes) เพื่อความยืดหยุ่น + +### 4. Security & Audit + +- **RBAC (Role-Based Access Control)**: ระบบสิทธิ์แบบ Hierarchical Scope +- **Audit Trail**: บันทึกผู้สร้าง/แก้ไข และเวลาในทุกตาราง +- **Two-Phase File Upload**: ป้องกันไฟล์ขยะด้วย Temporary Storage + +# 2. Database Schema Overview (ERD) + +### Entity Relationship Diagram + +```mermaid +erDiagram + %% Core Entities + organizations ||--o{ users : "employs" + projects ||--o{ contracts : "contains" + projects ||--o{ correspondences : "manages" + + %% RBAC + users ||--o{ user_assignments : "has" + roles ||--o{ user_assignments : "assigned_to" + roles ||--o{ role_permissions : "has" + permissions ||--o{ role_permissions : "granted_by" + + %% Correspondences + correspondences ||--o{ correspondence_revisions : "has_revisions" + correspondence_types ||--o{ correspondences : "categorizes" + correspondence_status ||--o{ correspondence_revisions : "defines_state" + disciplines ||--o{ correspondences : "classifies" + + %% RFAs + rfas ||--o{ rfa_revisions : "has_revisions" + rfa_types ||--o{ rfas : "categorizes" + rfa_status_codes ||--o{ rfa_revisions : "defines_state" + rfa_approve_codes ||--o{ rfa_revisions : "defines_result" + disciplines ||--o{ rfas : "classifies" + + %% Drawings + shop_drawings ||--o{ shop_drawing_revisions : "has_revisions" + shop_drawing_main_categories ||--o{ shop_drawings : "categorizes" + shop_drawing_sub_categories ||--o{ shop_drawings : "sub_categorizes" + + %% Attachments + attachments ||--o{ correspondence_attachments : "attached_to" + correspondences ||--o{ correspondence_attachments : "has" +``` + +--- + +# 3. Data Dictionary V1.8.0 + +> หมายเหตุ: PK = Primary Key, FK = Foreign Key, AI = AUTO_INCREMENT. รูปแบบ Soft Delete จะปรากฏ Column `deleted_at DATETIME NULL` เป็นมาตรฐาน + +## **1. 🏢 Core & Master Data Tables (องค์กร, โครงการ, สัญญา)** + +### 1.1 organization_roles + +- - Purpose **: MASTER TABLE FOR organization role TYPES IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ----------- | ----------- | --------------------------- | ---------------------------------------------------------------- | + | id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR organization role | | role_name | VARCHAR(20) | NOT NULL, + UNIQUE | Role name ( + CONTRACTOR, + THIRD PARTY + ) | + | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | + | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (role_name) ** Business Rules \*\*: - Predefined system roles FOR organization TYPES - Cannot be deleted IF referenced by organizations --- + +### 1.2 organizations + +- - Purpose **: MASTER TABLE storing ALL organizations involved IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ----------------- | ------------ | ----------------------------------- | ---------------------------------------- | + | id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR organization | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | organization_code | VARCHAR(20) | NOT NULL, + UNIQUE | Organization code (e.g., 'กทท.', 'TEAM') | | organization_name | VARCHAR(255) | NOT NULL | FULL organization name | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS (1 = active, 0 = inactive) | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last + UPDATE timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (organization_code) - INDEX (is_active) ** Relationships \*\*: - Referenced by: users, + project_organizations, + contract_organizations, + correspondences, + circulations --- + + ### 1.3 projects + - - Purpose **: MASTER TABLE FOR ALL projects IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ------------ | ------------ | --------------------------- | ----------------------------- | + | id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR project | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | project_code | VARCHAR(50) | NOT NULL, + UNIQUE | Project code (e.g., 'LCBP3') | | project_name | VARCHAR(255) | NOT NULL | FULL project name | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | + | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | + | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | + ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (project_code) - INDEX (is_active) ** Relationships \*\*: - Referenced by: contracts, + correspondences, + document_number_formats, + drawings --- + + ### 1.4 contracts + - - Purpose **: MASTER TABLE FOR contracts within projects | COLUMN Name | Data TYPE | Constraints | Description | | ------------- | ------------ | ----------------------------------- | ------------------------------ | + | id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR contract | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | project_id | INT | NOT NULL, + FK | Reference TO projects TABLE | | contract_code | VARCHAR(50) | NOT NULL, + UNIQUE | Contract code | | contract_name | VARCHAR(255) | NOT NULL | FULL contract name | | description | TEXT | NULL | Contract description | | start_date | DATE | NULL | Contract START date | | end_date | DATE | NULL | Contract + END date | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last + UPDATE timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (contract_code) - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - INDEX (project_id, is_active) ** Relationships \*\*: - Parent: projects - Referenced by: contract_organizations, + user_assignments --- + + ### 1.5 disciplines (NEW v1.5.1) + - - Purpose **: เก็บข้อมูลสาขางาน (Disciplines) แยกตามสัญญา (Req 6B) | COLUMN Name | Data TYPE | Constraints | Description | |: -------------- | :----------- | :----------- | :--------------------- | + | id | INT | PK, + AI | UNIQUE identifier | | contract_id | INT | FK, + NOT NULL | ผูกกับสัญญา | | discipline_code | VARCHAR(10) | NOT NULL | รหัสสาขา (เช่น GEN, STR) | | code_name_th | VARCHAR(255) | NULL | ชื่อไทย | | code_name_en | VARCHAR(255) | NULL | ชื่ออังกฤษ | | is_active | TINYINT(1) | DEFAULT 1 | สถานะการใช้งาน | ** INDEXES \*\*: - UNIQUE (contract_id, discipline_code) --- + + ## **2. 👥 Users & RBAC Tables (ผู้ใช้, สิทธิ์, บทบาท)** + + ### 2.1 users + - - Purpose **: MASTER TABLE storing ALL system users | COLUMN Name | Data TYPE | Constraints | Description | | ----------------------- | ------------ | ----------------------------------- | -------------------------------- | + | user_id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR user | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | username | VARCHAR(50) | NOT NULL, + UNIQUE | Login username | | password_hash | VARCHAR(255) | NOT NULL | Hashed PASSWORD (bcrypt) | | first_name | VARCHAR(50) | NULL | User 's first name | + | last_name | VARCHAR(50) | NULL | User' s last name | | email | VARCHAR(100) | NOT NULL, + UNIQUE | Email address | | line_id | VARCHAR(100) | NULL | LINE messenger ID | | primary_organization_id | INT | NULL, + FK | PRIMARY organization affiliation | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | | failed_attempts | INT | DEFAULT 0 | Failed login attempts counter | | locked_until | DATETIME | NULL | Account LOCK expiration time | | last_login_at | TIMESTAMP | NULL | Last successful login timestamp | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last + UPDATE timestamp | | deleted_at | DATETIME | NULL | Deleted at | ** INDEXES **: - PRIMARY KEY (user_id) - UNIQUE (username) - UNIQUE (email) - FOREIGN KEY (primary_organization_id) REFERENCES organizations(id) ON DELETE + SET NULL - INDEX (is_active) - INDEX (email) ** Relationships \*\*: - Parent: organizations (primary_organization_id) - Referenced by: user_assignments, + audit_logs, + notifications, + circulation_routings --- + + ### 2.2 roles + - - Purpose **: MASTER TABLE defining system roles WITH scope levels | COLUMN Name | Data TYPE | Constraints | Description | | ----------- | ------------ | --------------------------- | ---------------------------------------------------- | + | role_id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR role | | role_name | VARCHAR(100) | NOT NULL | Role name (e.g., 'Superadmin', 'Document Control') | | scope | ENUM | NOT NULL | Scope LEVEL: GLOBAL, + Organization, + Project, + Contract | | description | TEXT | NULL | Role description | | is_system | BOOLEAN | DEFAULT FALSE | System role flag (cannot be deleted) | + | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | + | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | + ** INDEXES **: - PRIMARY KEY (role_id) - INDEX (scope) ** Relationships \*\*: - Referenced by: role_permissions, + user_assignments --- + + ### 2.3 permissions + - - Purpose **: MASTER TABLE defining ALL system permissions | COLUMN Name | Data TYPE | Constraints | Description | | --------------- | ------------ | --------------------------- | ------------------------------------------------------ | + | permission_id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR permission | | permission_name | VARCHAR(100) | NOT NULL, + UNIQUE | Permission code (e.g., 'rfas.create', 'document.view') | | description | TEXT | NULL | Permission description | | module | VARCHAR(50) | NULL | Related module name | | scope_level | ENUM | NULL | Scope: GLOBAL, + ORG, + PROJECT | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | + | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | + | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | + ** INDEXES **: - PRIMARY KEY (permission_id) - UNIQUE (permission_name) - INDEX (module) - INDEX (scope_level) - INDEX (is_active) ** Relationships \*\*: - Referenced by: role_permissions --- + + ### 2.4 role_permissions + - - Purpose **: Junction TABLE mapping roles TO permissions (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | ------------- | --------- | --------------- | ------------------------------ | + | role_id | INT | PRIMARY KEY, + FK | Reference TO roles TABLE | | permission_id | INT | PRIMARY KEY, + FK | Reference TO permissions TABLE | ** INDEXES **: - PRIMARY KEY (role_id, permission_id) - FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE - FOREIGN KEY (permission_id) REFERENCES permissions(permission_id) ON DELETE CASCADE - INDEX (permission_id) ** Relationships \*\*: - Parent: roles, + permissions --- + + ### 2.5 user_assignments + - - Purpose **: Junction TABLE assigning users TO roles WITH scope context | COLUMN Name | Data TYPE | Constraints | Description | | ------------------- | --------- | --------------------------- | ---------------------------------- | + | id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier | | user_id | INT | NOT NULL, + FK | Reference TO users TABLE | | role_id | INT | NOT NULL, + FK | Reference TO roles TABLE | | organization_id | INT | NULL, + FK | Organization scope (IF applicable) | | project_id | INT | NULL, + FK | Project scope (IF applicable) | | contract_id | INT | NULL, + FK | Contract scope (IF applicable) | | assigned_by_user_id | INT | NULL, + FK | User who made the assignment | | assigned_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Assignment timestamp | ** INDEXES **: - PRIMARY KEY (id) - FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE - FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE - FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE - FOREIGN KEY (assigned_by_user_id) REFERENCES users(user_id) - INDEX (user_id, role_id) - INDEX (organization_id) - INDEX (project_id) - INDEX (contract_id) ** Relationships \*\*: - Parent: users, + roles, + organizations, + projects, + contracts --- + + ### 2.6 project_organizations + - - Purpose **: Junction TABLE linking projects TO participating organizations (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | --------------- | --------- | --------------- | -------------------------------- | + | project_id | INT | PRIMARY KEY, + FK | Reference TO projects TABLE | | organization_id | INT | PRIMARY KEY, + FK | Reference TO organizations TABLE | ** INDEXES **: - PRIMARY KEY (project_id, organization_id) - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE - INDEX (organization_id) ** Relationships \*\*: - Parent: projects, + organizations --- + + ### 2.7 contract_organizations + - - Purpose \*\*: Junction TABLE linking contracts TO participating organizations WITH roles (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | ---------------- | ------------ | --------------- | ------------------------------------------------------------------------- | + | contract_id | INT | PRIMARY KEY, + FK | Reference TO contracts TABLE | | organization_id | INT | PRIMARY KEY, + FK | Reference TO organizations TABLE | | role_in_contract | VARCHAR(100) | NULL | Organization 's role in contract (Owner, Designer, Consultant, Contractor) | + +**Indexes**: + +- PRIMARY KEY (contract_id, organization_id) +- FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE +- FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE +- INDEX (organization_id) +- INDEX (role_in_contract) + +**Relationships**: + +- Parent: contracts, organizations + +--- + +### 2.8 user_preferences (NEW v1.5.1) + +**Purpose**: เก็บการตั้งค่าส่วนตัวของผู้ใช้ (Req 5.5, 6.8.3) + +| Column Name | Data Type | Constraints | Description | +| :----------- | :---------- | :---------------- | :----------------- | +| user_id | INT | PK, FK | User ID | +| notify_email | BOOLEAN | DEFAULT TRUE | รับอีเมลแจ้งเตือน | +| notify_line | BOOLEAN | DEFAULT TRUE | รับไลน์แจ้งเตือน | +| digest_mode | BOOLEAN | DEFAULT FALSE | รับแจ้งเตือนแบบรวม | +| ui_theme | VARCHAR(20) | DEFAULT ' light ' | UI Theme | + +--- + +### 2.9 refresh_tokens (UPDATE v1.8.1) + +**Purpose**: เก็บ Refresh Tokens สำหรับการทำ Authentication และ Token Rotation (รองรับ Grace Period) + +| Column Name | Data Type | Constraints | Description | +| :---------------- | :----------- | :------------------------ | :--------------------------------------------------------- | +| token_id | INT | PK, AI | Unique Token ID | +| user_id | INT | FK, NOT NULL | เจ้าของ Token | +| token_hash | VARCHAR(255) | NOT NULL | Hash ของ Refresh Token (Security) | +| expires_at | DATETIME | NOT NULL | วันหมดอายุของ Token | +| is_revoked | BOOLEAN | DEFAULT FALSE | สถานะถูกยกเลิก (True = ใช้งานไม่ได้) | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | เวลาที่สร้าง | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | เวลาที่แก้ไขล่าสุด (ใช้ตรวจสอบ Grace Period ขณะหมุน Token) | +| replaced_by_token | VARCHAR(255) | NULL | Token ใหม่ที่มาแทนที่ (กรณี Token Rotation) | + +**Indexes**: + +- PRIMARY KEY (token_id) +- FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE +- INDEX (user_id) + +**Relationships**: + +- Parent: users + +--- + +## **3. ✉️ Correspondences Tables (เอกสารหลัก, Revisions, Workflows)** + +### 3.1 correspondence_types + +**Purpose**: Master table for correspondence document types + +| Column Name | Data Type | Constraints | Description | +| ----------- | ------------ | --------------------------- | --------------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique identifier | +| type_code | VARCHAR(50) | NOT NULL, UNIQUE | Type code (e.g., ' RFA ', ' RFI ', ' TRANSMITTAL ') | +| type_name | VARCHAR(255) | NOT NULL | Full type name | +| sort_order | INT | DEFAULT 0 | Display order | +| is_active | TINYINT(1) | DEFAULT 1 | Active status | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Last update timestamp | +| deleted_at | DATETIME | NULL | Soft delete timestamp | + +**Indexes**: + +- PRIMARY KEY (id) +- UNIQUE (type_code) +- INDEX (is_active) +- INDEX (sort_order) + +**Relationships**: + +- Referenced by: correspondences, document_number_formats, document_number_counters + +--- + +### 3.2 correspondence_sub_types (NEW v1.5.1) + +**Purpose**: เก็บประเภทหนังสือย่อย (Sub Types) สำหรับ Mapping เลขรหัส (Req 6B) + +| Column Name | Data Type | Constraints | Description | +| :--------------------- | :----------- | :----------- | :--------------------------- | +| id | INT | PK, AI | Unique identifier | +| contract_id | INT | FK, NOT NULL | ผูกกับสัญญา | +| correspondence_type_id | INT | FK, NOT NULL | ผูกกับประเภทเอกสารหลัก | +| sub_type_code | VARCHAR(20) | NOT NULL | รหัสย่อย (เช่น MAT, SHP) | +| sub_type_name | VARCHAR(255) | NULL | ชื่อประเภทหนังสือย่อย | +| sub_type_number | VARCHAR(10) | NULL | เลขรหัสสำหรับ Running Number | + +--- + +### 3.3 correspondences (UPDATE v1.7.0) + +**Purpose**: Master table for correspondence documents (non-revisioned data) + +| Column Name | Data Type | Constraints | Description | +| ------------------------- | ------------ | --------------------------- | ------------------------------------------ | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Master correspondence ID | +| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | +| correspondence_number | VARCHAR(100) | NOT NULL | Document number (from numbering system) | +| correspondence_type_id | INT | NOT NULL, FK | Reference to correspondence_types | +| **discipline_id** | **INT** | **NULL, FK** | **[NEW] สาขางาน (ถ้ามี)** | +| is_internal_communication | TINYINT(1) | DEFAULT 0 | Internal (1) or external (0) communication | +| project_id | INT | NOT NULL, FK | Reference to projects table | +| originator_id | INT | NULL, FK | Originating organization | +| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| created_by | INT | NULL, FK | User who created the record | +| deleted_at | DATETIME | NULL | Soft delete timestamp | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE RESTRICT +- **FOREIGN KEY (discipline_id) REFERENCES disciplines(id) ON DELETE SET NULL** +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- FOREIGN KEY (originator_id) REFERENCES organizations(id) ON DELETE SET NULL +- FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL +- UNIQUE KEY (project_id, correspondence_number) +- UNIQUE INDEX idx_correspondences_uuid (uuid) +- INDEX (correspondence_type_id) +- INDEX (originator_id) +- INDEX (deleted_at) + +**Relationships**: + +- Parent: correspondence_types, **disciplines**, projects, organizations, users +- Children: correspondence_revisions, correspondence_recipients, correspondence_tags, correspondence_references, correspondence_attachments, circulations, transmittals + +--- + +### 3.4 correspondence_revisions (UPDATE v1.7.0) + +**Purpose**: Child table storing revision history of correspondences (1:N) + +| Column Name | Data Type | Constraints | Description | +| ------------------------ | ------------ | --------------------------------- | ------------------------------------------------------------ | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | +| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | +| correspondence_id | INT | NOT NULL, FK | Master correspondence ID | +| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | +| revision_label | VARCHAR(10) | NULL | Display revision (A, B, 1.1...) | +| is_current | BOOLEAN | DEFAULT FALSE | Current revision flag | +| correspondence_status_id | INT | NOT NULL, FK | Current status of this revision | +| title | VARCHAR(255) | NOT NULL | Document title | +| document_date | DATE | NULL | Document date | +| issued_date | DATETIME | NULL | Issue date | +| received_date | DATETIME | NULL | Received date | +| due_date | DATETIME | NULL | Due date for response | +| description | TEXT | NULL | Revision description | +| details | JSON | NULL | Type-specific details (e.g., RFI questions) | +| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | +| created_by | INT | NULL, FK | User who created revision | +| updated_by | INT | NULL, FK | User who last updated | +| v_ref_project_id | INT | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Project ID จาก JSON details เพื่อทำ Index | + +| v_doc_subtype | VARCHAR(50) | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Type จาก JSON details | +| schema_version | INT | DEFAULT 1 | Version of the schema used with this details | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (correspondence_status_id) REFERENCES correspondence_status(id) ON DELETE RESTRICT +- FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL +- FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL +- UNIQUE KEY (correspondence_id, revision_number) +- UNIQUE KEY (correspondence_id, is_current) +- INDEX (correspondence_status_id) +- INDEX (is_current) +- INDEX (document_date) +- INDEX (issued_date) +- INDEX (v_ref_project_id) +- INDEX (v_doc_subtype) + +--- + +### 3.5 correspondence_recipients + +**Purpose**: Junction table for correspondence recipients (TO/CC) (M:N) + +| Column Name | Data Type | Constraints | Description | +| ------------------------- | -------------------- | --------------- | ---------------------------- | +| correspondence_id | INT | PRIMARY KEY, FK | Reference to correspondences | +| recipient_organization_id | INT | PRIMARY KEY, FK | Recipient organization | +| recipient_type | ENUM(' TO ', ' CC ') | PRIMARY KEY | Recipient type | + +**Indexes**: + +- PRIMARY KEY (correspondence_id, recipient_organization_id, recipient_type) +- FOREIGN KEY (correspondence_id) REFERENCES correspondence_revisions(correspondence_id) ON DELETE CASCADE +- FOREIGN KEY (recipient_organization_id) REFERENCES organizations(id) ON DELETE RESTRICT +- INDEX (recipient_organization_id) +- INDEX (recipient_type) + +**Relationships**: + +- Parent: correspondences, organizations + +--- + +### 3.6 tags (UPDATE v1.8.0) + +**Purpose**: Master table for document tagging system (Supports multi-tenant per project) + +| Column Name | Data Type | Constraints | Description | +| -------------- | --------------- | ----------------------------------- | --------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique tag ID | +| **project_id** | **INT** | **NULL, FK** | **[NEW] Project scope (NULL = Global)** | +| tag_name | VARCHAR(100) | NOT NULL | Tag name | +| **color_code** | **VARCHAR(30)** | **DEFAULT 'default'** | **[NEW] UI Color/Class Code** | +| description | TEXT | NULL | Tag description | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | +| **created_by** | **INT** | **NULL, FK** | **[NEW] User who created the tag** | +| **deleted_at** | **DATETIME** | **NULL** | **[NEW] Soft delete timestamp** | + +**Indexes**: + +- PRIMARY KEY (id) +- **FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE** +- **FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL** +- **UNIQUE KEY (project_id, tag_name)** +- INDEX (deleted_at) + +**Relationships**: + +- Parent: projects, users +- Referenced by: correspondence_tags + +--- + +### 3.7 correspondence_tags (UPDATE v1.8.0) + +**Purpose**: Junction table linking correspondences to tags (M:N) + +| Column Name | Data Type | Constraints | Description | +| ----------------- | --------- | --------------- | ---------------------------- | +| correspondence_id | INT | PRIMARY KEY, FK | Reference to correspondences | +| tag_id | INT | PRIMARY KEY, FK | Reference to tags | + +**Indexes**: + +- PRIMARY KEY (correspondence_id, tag_id) +- FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +- **INDEX idx_tag_lookup (tag_id) - For reverse lookup (Find documents by tag)** + +**Relationships**: + +- Parent: correspondences, tags + +--- + +### 3.8 correspondence_references + +**Purpose**: Junction table for cross-referencing correspondences (M:N) + +| Column Name | Data Type | Constraints | Description | +| --------------------- | --------- | --------------- | ------------------------------------- | +| src_correspondence_id | INT | PRIMARY KEY, FK | Source correspondence ID | +| tgt_correspondence_id | INT | PRIMARY KEY, FK | Target (referenced) correspondence ID | + +**Indexes**: + +- PRIMARY KEY (src_correspondence_id, tgt_correspondence_id) +- FOREIGN KEY (src_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (tgt_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- INDEX (tgt_correspondence_id) + +**Relationships**: + +- Parent: correspondences (both sides) + +--- + +## **4. 📐 approval: RFA Tables (เอกสารขออนุมัติ, Workflows)** + +### 4.1 rfa_types (UPDATE v1.7.0) + +**Purpose**: Master table for RFA (Request for Approval) types + +| Column Name | Data Type | Constraints | Description | +| :----------- | :----------- | :-------------------------- | :---------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique identifier | +| contract_id | INT | NOT NULL, FK | Contract reference | +| type_code | VARCHAR(20) | NOT NULL | Type code (DDW, SDW, ADW, DOC, MAT, etc.) | +| type_name_th | VARCHAR(100) | NOT NULL | Full type name (TH) | +| type_name_en | VARCHAR(100) | NOT NULL | Full type name (EN) | +| remark | TEXT | NULL | Remark | +| is_active | TINYINT(1) | DEFAULT 1 | Active status | + +**Indexes**: + +- PRIMARY KEY (id) +- UNIQUE (contract_id, type_code) +- FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE +- INDEX (is_active) + +**Relationships**: + +- Referenced by: rfas + +--- + +### 4.2 rfa_status_codes + +**Purpose**: Master table for RFA status codes + +| Column Name | Data Type | Constraints | Description | +| ----------- | ------------ | --------------------------- | --------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique identifier | +| status_code | VARCHAR(20) | NOT NULL, UNIQUE | Status code (DFT, FAP, FRE, etc.) | +| status_name | VARCHAR(100) | NOT NULL | Full status name | +| description | TEXT | NULL | Status description | +| sort_order | INT | DEFAULT 0 | Display order | +| is_active | TINYINT(1) | DEFAULT 1 | Active status | + +**Indexes**: + +- PRIMARY KEY (id) +- UNIQUE (status_code) +- INDEX (is_active) +- INDEX (sort_order) + +**Relationships**: + +- Referenced by: rfa_revisions + +--- + +### 4.3 rfa_approve_codes + +**Purpose**: Master table for RFA approval result codes + +| Column Name | Data Type | Constraints | Description | +| ------------ | ------------ | --------------------------- | -------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique identifier | +| approve_code | VARCHAR(20) | NOT NULL, UNIQUE | Approval code (1A, 1C, 3R, etc.) | +| approve_name | VARCHAR(100) | NOT NULL | Full approval name | +| description | TEXT | NULL | Code description | +| sort_order | INT | DEFAULT 0 | Display order | +| is_active | TINYINT(1) | DEFAULT 1 | Active status | + +**Indexes**: + +- PRIMARY KEY (id) +- UNIQUE (approve_code) +- INDEX (is_active) +- INDEX (sort_order) + +**Relationships**: + +- Referenced by: rfa_revisions + +--- + +### 4.4 rfas (UPDATE v1.7.0) + +**Purpose**: Master table for RFA documents (non-revisioned data) + +| Column Name | Data Type | Constraints | Description | +| :---------- | :-------- | :------------------------ | :------------------------------------------ | +| id | INT | PK, FK | Master RFA ID (Shared with correspondences) | +| rfa_type_id | INT | NOT NULL, FK | Reference to rfa_types | +| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| created_by | INT | NULL, FK | User who created the record | +| deleted_at | DATETIME | NULL | Soft delete timestamp | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (rfa_type_id) REFERENCES rfa_types(id) +- FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL +- INDEX (rfa_type_id) +- INDEX (deleted_at) + +**Relationships**: + +- Parent: correspondences, rfa_types, users +- Children: rfa_revisions + +--- + +### 4.5 rfa_revisions (UPDATE v1.7.0) + +**Purpose**: Child table storing revision history of RFAs (1:N) + +| Column Name | Data Type | Constraints | Description | +| ------------------- | --------- | --------------------------------- | --------------------------------------------------------------- | +| id | INT | PK, FK | Master Revision ID (Shared with correspondence_revisions) | +| rfa_status_code_id | INT | NOT NULL, FK | Current RFA status | +| rfa_approve_code_id | INT | NULL, FK | Approval result code | +| details | JSON | NULL | Type-specific details (e.g., RFI questions) | +| v_ref_drawing_count | INT | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Drawing Count จาก JSON details เพื่อทำ Index | +| schema_version | INT | DEFAULT 1 | Version of the schema used with this details | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (id) REFERENCES correspondence_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (rfa_status_code_id) REFERENCES rfa_status_codes(id) +- FOREIGN KEY (rfa_approve_code_id) REFERENCES rfa_approve_codes(id) ON DELETE SET NULL +- INDEX (rfa_status_code_id) +- INDEX (rfa_approve_code_id) +- INDEX (v_ref_drawing_count): ตัวอย่างการ Index ข้อมูลตัวเลขใน JSON + +**Relationships**: + +- Parent: correspondence_revisions, rfas, rfa_status_codes, rfa_approve_codes +- Children: rfa_items + +--- + +### 4.6 rfa_items + +**Purpose**: Child table linking RFA revisions to drawing revisions that require approval + +| Column Name | Data Type | Constraints | Description | +| :-------------------------- | :---------------------- | :-------------- | :--------------------------- | +| id | INT | PRIMARY KEY, AI | Unique identifier | +| rfa_revision_id | INT | NOT NULL, FK | RFA Revision ID | +| item_type | ENUM('SHOP','AS_BUILT') | NOT NULL | Drawing reference type | +| shop_drawing_revision_id | INT | NULL, FK | Shop drawing revision ID | +| asbuilt_drawing_revision_id | INT | NULL, FK | As-Built drawing revision ID | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (rfa_revision_id) REFERENCES rfa_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE +- UNIQUE KEY (rfa_revision_id, shop_drawing_revision_id) +- UNIQUE KEY (rfa_revision_id, asbuilt_drawing_revision_id) +- INDEX (item_type) +- INDEX (shop_drawing_revision_id) +- INDEX (asbuilt_drawing_revision_id) + +**Relationships**: + +- Parent: rfa_revisions, shop_drawing_revisions, asbuilt_drawing_revisions + +**Business Rules**: + +- `correspondences.correspondence_type_id` for an RFA must always point to `correspondence_types.type_code = 'RFA'` +- `rfas.rfa_type_id` stores the selected RFA subtype +- `DDW` and `SDW` RFA types must reference `shop_drawing_revisions` +- `ADW` RFA types must reference `asbuilt_drawing_revisions` +- Each `rfa_items` row must reference exactly one drawing revision target according to `item_type` +- One RFA can contain multiple drawing references +- One drawing revision can be referenced by multiple RFAs + +--- + +--- + +## **5. 📐 Drawings Tables (แบบ, หมวดหมู่)** + +### 5.1 contract_drawing_volumes + +**Purpose**: Master table for contract drawing volume classification + +| Column Name | Data Type | Constraints | Description | +| ----------- | ------------ | ----------------------------------- | ------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique volume ID | +| project_id | INT | NOT NULL, FK | Reference to projects | +| volume_code | VARCHAR(50) | NOT NULL | Volume code | +| volume_name | VARCHAR(255) | NOT NULL | Volume name | +| description | TEXT | NULL | Volume description | +| sort_order | INT | DEFAULT 0 | Display order | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- UNIQUE KEY (project_id, volume_code) +- INDEX (sort_order) + +**Relationships**: + +- Parent: projects +- Referenced by: contract_drawings + +**Business Rules**: + +- Volume codes must be unique within a project +- Used for organizing large sets of contract drawings + +--- + +### 5.2 contract_drawing_cats + +**Purpose**: Master table for contract drawing main categories + +| Column Name | Data Type | Constraints | Description | +| ----------- | ------------ | ----------------------------------- | ------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique category ID | +| project_id | INT | NOT NULL, FK | Reference to projects | +| cat_code | VARCHAR(50) | NOT NULL | Category code | +| cat_name | VARCHAR(255) | NOT NULL | Category name | +| description | TEXT | NULL | Category description | +| sort_order | INT | DEFAULT 0 | Display order | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- UNIQUE KEY (project_id, cat_code) +- INDEX (sort_order) + +**Relationships**: + +- Parent: projects +- Referenced by: contract_drawing_subcat_cat_maps + +**Business Rules**: + +- Category codes must be unique within a project +- Hierarchical relationship with sub-categories via mapping table + +--- + +### 5.3 contract_drawing_sub_cats + +**Purpose**: Master table for contract drawing sub-categories + +| Column Name | Data Type | Constraints | Description | +| ------------ | ------------ | ----------------------------------- | ------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique sub-category ID | +| project_id | INT | NOT NULL, FK | Reference to projects | +| sub_cat_code | VARCHAR(50) | NOT NULL | Sub-category code | +| sub_cat_name | VARCHAR(255) | NOT NULL | Sub-category name | +| description | TEXT | NULL | Sub-category description | +| sort_order | INT | DEFAULT 0 | Display order | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- UNIQUE KEY (project_id, sub_cat_code) +- INDEX (sort_order) + +**Relationships**: + +- Parent: projects +- Referenced by: contract_drawings, contract_drawing_subcat_cat_maps + +**Business Rules**: + +- Sub-category codes must be unique within a project +- Can be mapped to multiple main categories via mapping table + +--- + +### 5.4 contract_drawing_subcat_cat_maps (UPDATE v1.7.0) + +**Purpose**: Junction table mapping sub-categories to main categories (M:N) + +| Column Name | Data Type | Constraints | Description | +| ----------- | --------- | ------------------------------- | -------------------------- | +| **id** | **INT** | **PRIMARY KEY, AUTO_INCREMENT** | **Unique mapping ID** | +| project_id | INT | NOT NULL, FK | Reference to projects | +| sub_cat_id | INT | NOT NULL, FK | Reference to sub-category | +| cat_id | INT | NOT NULL, FK | Reference to main category | + +**Indexes**: + +- PRIMARY KEY (id) +- **UNIQUE KEY (project_id, sub_cat_id, cat_id)** +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- FOREIGN KEY (sub_cat_id) REFERENCES contract_drawing_sub_cats(id) ON DELETE CASCADE +- FOREIGN KEY (cat_id) REFERENCES contract_drawing_cats(id) ON DELETE CASCADE +- INDEX (sub_cat_id) +- INDEX (cat_id) + +**Relationships**: + +- Parent: projects, contract_drawing_sub_cats, contract_drawing_cats +- Referenced by: contract_drawings + +**Business Rules**: + +- Allows flexible categorization +- One sub-category can belong to multiple main categories +- Composite uniqueness enforced via UNIQUE constraint + +--- + +### 5.5 contract_drawings (UPDATE v1.7.0) + +**Purpose**: Master table for contract drawings (from contract specifications) + +| Column Name | Data Type | Constraints | Description | +| --------------- | ------------ | ----------------------------------- | ---------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID | +| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | +| project_id | INT | NOT NULL, FK | Reference to projects | +| condwg_no | VARCHAR(255) | NOT NULL | Contract drawing number | +| title | VARCHAR(255) | NOT NULL | Drawing title | +| **map_cat_id** | **INT** | **NULL, FK** | **[CHANGED] Reference to mapping table** | +| volume_id | INT | NULL, FK | Reference to volume | +| **volume_page** | **INT** | **NULL** | **[NEW] Page number within volume** | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | +| deleted_at | DATETIME | NULL | Soft delete timestamp | +| updated_by | INT | NULL, FK | User who last updated | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- **FOREIGN KEY (map_cat_id) REFERENCES contract_drawing_subcat_cat_maps(id) ON DELETE RESTRICT** +- FOREIGN KEY (volume_id) REFERENCES contract_drawing_volumes(id) ON DELETE RESTRICT +- FOREIGN KEY (updated_by) REFERENCES users(user_id) +- UNIQUE KEY (project_id, condwg_no) +- UNIQUE INDEX idx_contract_drawings_uuid (uuid) +- INDEX (map_cat_id) +- INDEX (volume_id) +- INDEX (deleted_at) + +**Relationships**: + +- Parent: projects, contract_drawing_subcat_cat_maps, contract_drawing_volumes, users +- Referenced by: shop_drawing_revision_contract_refs, contract_drawing_attachments + +**Business Rules**: + +- Drawing numbers must be unique within a project +- Represents baseline/contract drawings +- Referenced by shop drawings for compliance tracking +- Soft delete preserves history +- **map_cat_id references the mapping table for flexible categorization** + +--- + +### 5.6 shop_drawing_main_categories (UPDATE v1.7.0) + +**Purpose**: Master table for shop drawing main categories (discipline-level) + +| Column Name | Data Type | Constraints | Description | +| ------------------ | ------------ | ----------------------------------- | ------------------------------------ | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique category ID | +| **project_id** | **INT** | **NOT NULL, FK** | **[NEW] Reference to projects** | +| main_category_code | VARCHAR(50) | NOT NULL, UNIQUE | Category code (ARCH, STR, MEP, etc.) | +| main_category_name | VARCHAR(255) | NOT NULL | Category name | +| description | TEXT | NULL | Category description | +| sort_order | INT | DEFAULT 0 | Display order | +| is_active | TINYINT(1) | DEFAULT 1 | Active status | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + +**Indexes**: + +- PRIMARY KEY (id) +- **FOREIGN KEY (project_id) REFERENCES projects(id)** +- UNIQUE (main_category_code) +- INDEX (is_active) +- INDEX (sort_order) + +**Relationships**: + +- **Parent: projects** +- Referenced by: shop_drawings, asbuilt_drawings + +**Business Rules**: + +- **[CHANGED] Project-specific categories (was global)** +- Typically represents engineering disciplines + +--- + +### 5.7 shop_drawing_sub_categories (UPDATE v1.7.0) + +**Purpose**: Master table for shop drawing sub-categories (component-level) + +| Column Name | Data Type | Constraints | Description | +| ----------------- | ------------ | ----------------------------------- | ----------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique sub-category ID | +| **project_id** | **INT** | **NOT NULL, FK** | **[NEW] Reference to projects** | +| sub_category_code | VARCHAR(50) | NOT NULL, UNIQUE | Sub-category code (STR-COLUMN, ARCH-DOOR, etc.) | +| sub_category_name | VARCHAR(255) | NOT NULL | Sub-category name | +| description | TEXT | NULL | Sub-category description | +| sort_order | INT | DEFAULT 0 | Display order | +| is_active | TINYINT(1) | DEFAULT 1 | Active status | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + +**Indexes**: + +- PRIMARY KEY (id) +- **FOREIGN KEY (project_id) REFERENCES projects(id)** +- UNIQUE (sub_category_code) +- INDEX (is_active) +- INDEX (sort_order) + +**Relationships**: + +- **Parent: projects** +- Referenced by: shop_drawings, asbuilt_drawings + +**Business Rules**: + +- **[CHANGED] Project-specific sub-categories (was global)** +- **[REMOVED] No longer hierarchical under main categories** +- Represents specific drawing types or components + +--- + +### 5.8 shop_drawings (UPDATE v1.7.0) + +**Purpose**: Master table for shop drawings (contractor-submitted) + +| Column Name | Data Type | Constraints | Description | +| ---------------- | ------------ | ----------------------------------- | -------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID | +| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | +| project_id | INT | NOT NULL, FK | Reference to projects | +| drawing_number | VARCHAR(100) | NOT NULL, UNIQUE | Shop drawing number | +| main_category_id | INT | NOT NULL, FK | Reference to main category | +| sub_category_id | INT | NOT NULL, FK | Reference to sub-category | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | +| deleted_at | DATETIME | NULL | Soft delete timestamp | +| updated_by | INT | NULL, FK | User who last updated | + +**Indexes**: + +- PRIMARY KEY (id) +- UNIQUE (drawing_number) +- UNIQUE INDEX idx_shop_drawings_uuid (uuid) +- FOREIGN KEY (project_id) REFERENCES projects(id) +- FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id) +- FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id) +- FOREIGN KEY (updated_by) REFERENCES users(user_id) +- INDEX (project_id) +- INDEX (main_category_id) +- INDEX (sub_category_id) +- INDEX (deleted_at) + +**Relationships**: + +- Parent: projects, shop_drawing_main_categories, shop_drawing_sub_categories, users +- Children: shop_drawing_revisions + +**Business Rules**: + +- Drawing numbers are globally unique across all projects +- Represents contractor shop drawings +- Can have multiple revisions +- Soft delete preserves history +- **[CHANGED] Title moved to shop_drawing_revisions table** + +--- + +### 5.9 shop_drawing_revisions (UPDATE v1.7.0) + +**Purpose**: Child table storing revision history of shop drawings (1:N) + +| Column Name | Data Type | Constraints | Description | +| ------------------------- | ---------------- | --------------------------- | ---------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | +| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | +| shop_drawing_id | INT | NOT NULL, FK | Master shop drawing ID | +| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | +| revision_label | VARCHAR(10) | NULL | Display revision (A, B, C...) | +| revision_date | DATE | NULL | Revision date | +| **title** | **VARCHAR(500)** | **NOT NULL** | **[NEW] Drawing title** | +| description | TEXT | NULL | Revision description/changes | +| **legacy_drawing_number** | **VARCHAR(100)** | **NULL** | **[NEW] Original/legacy drawing number** | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (shop_drawing_id) REFERENCES shop_drawings(id) ON DELETE CASCADE +- UNIQUE KEY (shop_drawing_id, revision_number) +- UNIQUE INDEX idx_shop_drawing_revisions_uuid (uuid) +- INDEX (revision_date) + +**Relationships**: + +- Parent: shop_drawings +- Referenced by: rfa_items, shop_drawing_revision_contract_refs, shop_drawing_revision_attachments, asbuilt_revision_shop_revisions_refs + +**Business Rules**: + +- Revision numbers are sequential starting from 0 +- Each revision can reference multiple contract drawings +- Each revision can have multiple file attachments +- Linked to RFAs for approval tracking +- **[NEW] Title stored at revision level for version-specific naming** +- **[NEW] legacy_drawing_number supports data migration from old systems** + +--- + +### 5.10 shop_drawing_revision_contract_refs + +**Purpose**: Junction table linking shop drawing revisions to referenced contract drawings (M:N) + +| Column Name | Data Type | Constraints | Description | +| ------------------------ | --------- | --------------- | ---------------------------------- | +| shop_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to shop drawing revision | +| contract_drawing_id | INT | PRIMARY KEY, FK | Reference to contract drawing | + +**Indexes**: + +- PRIMARY KEY (shop_drawing_revision_id, contract_drawing_id) +- FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (contract_drawing_id) REFERENCES contract_drawings(id) ON DELETE CASCADE +- INDEX (contract_drawing_id) + +**Relationships**: + +- Parent: shop_drawing_revisions, contract_drawings + +**Business Rules**: + +- Tracks which contract drawings each shop drawing revision is based on +- Ensures compliance with contract specifications +- One shop drawing revision can reference multiple contract drawings + +--- + +### 5.11 asbuilt_drawings (NEW v1.7.0) + +**Purpose**: Master table for AS Built drawings (final construction records) + +| Column Name | Data Type | Constraints | Description | +| ---------------- | ------------ | ----------------------------------- | -------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID | +| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | +| project_id | INT | NOT NULL, FK | Reference to projects | +| drawing_number | VARCHAR(100) | NOT NULL, UNIQUE | AS Built drawing number | +| main_category_id | INT | NOT NULL, FK | Reference to main category | +| sub_category_id | INT | NOT NULL, FK | Reference to sub-category | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | +| deleted_at | DATETIME | NULL | Soft delete timestamp | +| updated_by | INT | NULL, FK | User who last updated | + +**Indexes**: + +- PRIMARY KEY (id) +- UNIQUE (drawing_number) +- UNIQUE INDEX idx_asbuilt_drawings_uuid (uuid) +- FOREIGN KEY (project_id) REFERENCES projects(id) +- FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id) +- FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id) +- FOREIGN KEY (updated_by) REFERENCES users(user_id) +- INDEX (project_id) +- INDEX (main_category_id) +- INDEX (sub_category_id) +- INDEX (deleted_at) + +**Relationships**: + +- Parent: projects, shop_drawing_main_categories, shop_drawing_sub_categories, users +- Children: asbuilt_drawing_revisions + +**Business Rules**: + +- Drawing numbers are globally unique across all projects +- Represents final as-built construction drawings +- Can have multiple revisions +- Soft delete preserves history +- Uses same category structure as shop drawings + +--- + +### 5.12 asbuilt_drawing_revisions (NEW v1.7.0) + +**Purpose**: Child table storing revision history of AS Built drawings (1:N) + +| Column Name | Data Type | Constraints | Description | +| --------------------- | ------------ | --------------------------- | -------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | +| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | +| asbuilt_drawing_id | INT | NOT NULL, FK | Master AS Built drawing ID | +| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | +| revision_label | VARCHAR(10) | NULL | Display revision (A, B, C...) | +| revision_date | DATE | NULL | Revision date | +| title | VARCHAR(500) | NOT NULL | Drawing title | +| description | TEXT | NULL | Revision description/changes | +| legacy_drawing_number | VARCHAR(100) | NULL | Original/legacy drawing number | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (asbuilt_drawing_id) REFERENCES asbuilt_drawings(id) ON DELETE CASCADE +- UNIQUE KEY (asbuilt_drawing_id, revision_number) +- UNIQUE INDEX idx_asbuilt_drawing_revisions_uuid (uuid) +- INDEX (revision_date) + +**Relationships**: + +- Parent: asbuilt_drawings +- Referenced by: asbuilt_revision_shop_revisions_refs, asbuilt_drawing_revision_attachments + +**Business Rules**: + +- Revision numbers are sequential starting from 0 +- Each revision can reference multiple shop drawing revisions +- Each revision can have multiple file attachments +- Title stored at revision level for version-specific naming +- legacy_drawing_number supports data migration from old systems + +--- + +### 5.13 asbuilt_revision_shop_revisions_refs (NEW v1.7.0) + +**Purpose**: Junction table linking AS Built drawing revisions to shop drawing revisions (M:N) + +| Column Name | Data Type | Constraints | Description | +| --------------------------- | --------- | --------------- | ---------------------------------- | +| asbuilt_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to AS Built revision | +| shop_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to shop drawing revision | + +**Indexes**: + +- PRIMARY KEY (asbuilt_drawing_revision_id, shop_drawing_revision_id) +- FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE +- INDEX (shop_drawing_revision_id) + +**Relationships**: + +- Parent: asbuilt_drawing_revisions, shop_drawing_revisions + +**Business Rules**: + +- Tracks which shop drawings each AS Built drawing revision is based on +- Maintains construction document lineage +- One AS Built revision can reference multiple shop drawing revisions +- Supports traceability from final construction to approved shop drawings + +--- + +### 5.14 asbuilt_drawing_revision_attachments (NEW v1.7.0) + +**Purpose**: Junction table linking AS Built drawing revisions to file attachments (M:N) + +| Column Name | Data Type | Constraints | Description | +| --------------------------- | ------------------------------------- | --------------- | ------------------------------------- | +| asbuilt_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to AS Built revision | +| attachment_id | INT | PRIMARY KEY, FK | Reference to attachment file | +| file_type | ENUM('PDF', 'DWG', 'SOURCE', 'OTHER') | NULL | File type classification | +| is_main_document | BOOLEAN | DEFAULT FALSE | Main document flag (1 = primary file) | + +**Indexes**: + +- PRIMARY KEY (asbuilt_drawing_revision_id, attachment_id) +- FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE +- INDEX (attachment_id) + +**Relationships**: + +- Parent: asbuilt_drawing_revisions, attachments + +**Business Rules**: + +- Each AS Built revision can have multiple file attachments +- File types: PDF (documents), DWG (CAD files), SOURCE (source files), OTHER (miscellaneous) +- One attachment can be marked as main document per revision +- Cascade delete when revision is deleted + +--- + +## **6. 🔄 Circulations Tables (ใบเวียนภายใน)** + +### 6.1 circulation_status_codes + +**Purpose**: Master table for circulation workflow status codes + +| Column Name | Data Type | Constraints | Description | +| ----------- | ----------- | --------------------------- | --------------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique status ID | +| code | VARCHAR(20) | NOT NULL, UNIQUE | Status code (OPEN, IN_REVIEW, COMPLETED, CANCELLED) | +| description | VARCHAR(50) | NOT NULL | Status description | +| sort_order | INT | DEFAULT 0 | Display order | +| is_active | TINYINT(1) | DEFAULT 1 | Active status | + +**Indexes**: + +- PRIMARY KEY (id) +- UNIQUE (code) +- INDEX (is_active) +- INDEX (sort_order) + +**Relationships**: + +- Referenced by: circulations + +**Seed Data**: 4 status codes + +- OPEN: Initial status when created +- IN_REVIEW: Under review by recipients +- COMPLETED: All recipients have responded +- CANCELLED: Withdrawn/cancelled + +--- + +### 6.2 circulations + +**Purpose**: Master table for internal circulation sheets (document routing) + +| Column Name | Data Type | Constraints | Description | +| ----------------------- | ------------ | ----------------------------------- | ----------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique circulation ID | +| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | +| correspondence_id | INT | UNIQUE, FK | Link to correspondence (1:1 relationship) | +| organization_id | INT | NOT NULL, FK | Organization that owns this circulation | +| circulation_no | VARCHAR(100) | NOT NULL | Circulation sheet number | +| circulation_subject | VARCHAR(500) | NOT NULL | Subject/title | +| circulation_status_code | VARCHAR(20) | NOT NULL, FK | Current status code | +| created_by_user_id | INT | NOT NULL, FK | User who created circulation | +| submitted_at | TIMESTAMP | NULL | Submission timestamp | +| closed_at | TIMESTAMP | NULL | Closure timestamp | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + +**Indexes**: + +- PRIMARY KEY (id) +- UNIQUE (correspondence_id) +- FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) +- FOREIGN KEY (organization_id) REFERENCES organizations(id) +- FOREIGN KEY (circulation_status_code) REFERENCES circulation_status_codes(code) +- FOREIGN KEY (created_by_user_id) REFERENCES users(user_id) +- INDEX (organization_id) +- UNIQUE INDEX idx_circulations_uuid (uuid) +- INDEX (circulation_status_code) +- INDEX (created_by_user_id) + +**Relationships**: + +- Parent: correspondences, organizations, circulation_status_codes, users +- Children: circulation_routings, circulation_attachments + +**Business Rules**: + +- Internal document routing within organization +- One-to-one relationship with correspondences +- Tracks document review/approval workflow +- Status progression: OPEN → IN_REVIEW → COMPLETED/CANCELLED + +--- + +## **7. 📤 Transmittals Tables (เอกสารนำส่ง)** + +### 7.1 transmittals + +**Purpose**: Child table for transmittal-specific data (1:1 with correspondences) + +| Column Name | Data Type | Constraints | Description | +| ----------------- | --------- | --------------- | --------------------------------------------------------- | +| correspondence_id | INT | PRIMARY KEY, FK | Reference to correspondences (1:1) | +| purpose | ENUM | NULL | Purpose: FOR_APPROVAL, FOR_INFORMATION, FOR_REVIEW, OTHER | +| remarks | TEXT | NULL | Additional remarks | + +**Indexes**: + +- PRIMARY KEY (correspondence_id) +- FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- INDEX (purpose) + +**Relationships**: + +- Parent: correspondences +- Children: transmittal_items + +**Business Rules**: + +- One-to-one relationship with correspondences +- Transmittal is a correspondence type for forwarding documents +- Contains metadata about the transmission + +--- + +### 7.2 transmittal_items + +**Purpose**: Junction table listing documents included in transmittal (M:N) + +| Column Name | Data Type | Constraints | Description | +| ---------------------- | ------------ | --------------------------- | --------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique item ID | +| transmittal_id | INT | NOT NULL, FK | Reference to transmittal | +| item_correspondence_id | INT | NOT NULL, FK | Reference to document being transmitted | +| quantity | INT | DEFAULT 1 | Number of copies | +| remarks | VARCHAR(255) | NULL | Item-specific remarks | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (transmittal_id) REFERENCES transmittals(correspondence_id) ON DELETE CASCADE +- FOREIGN KEY (item_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- UNIQUE KEY (transmittal_id, item_correspondence_id) +- INDEX (item_correspondence_id) + +**Relationships**: + +- Parent: transmittals, correspondences + +**Business Rules**: + +- One transmittal can contain multiple documents +- Tracks quantity of physical copies (if applicable) +- Links to any type of correspondence document + +--- + +## **8. 📎 File Management Tables (ไฟล์แนบ)** + +### 8.1 attachments + +**Purpose**: Central repository for all file attachments in the system + +| Column Name | Data Type | Constraints | Description | +| ------------------- | ------------ | --------------------------- | -------------------------------------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique attachment ID | +| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) | +| original_filename | VARCHAR(255) | NOT NULL | Original filename from upload | +| stored_filename | VARCHAR(255) | NOT NULL | System-generated unique filename | +| file_path | VARCHAR(500) | NOT NULL | Full file path on server (/share/dms-data/) | +| mime_type | VARCHAR(100) | NOT NULL | MIME type (application/pdf, image/jpeg, etc.) | +| file_size | INT | NOT NULL | File size in bytes | +| uploaded_by_user_id | INT | NOT NULL, FK | User who uploaded file | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Upload timestamp | +| is_temporary | BOOLEAN | DEFAULT TRUE | ระบุว่าเป็นไฟล์ชั่วคราว (ยังไม่ได้ Commit) | +| temp_id\* | VARCHAR(100) | NULL | ID ชั่วคราวสำหรับอ้างอิงตอน Upload Phase 1 (อาจใช้ร่วมกับ id หรือแยกก็ได้) | +| expires_at | DATETIME | NULL | เวลาหมดอายุของไฟล์ Temp (เพื่อให้ Cron Job ลบออก) | +| checksum | VARCHAR(64) | NULL | SHA-256 Checksum สำหรับ Verify File Integrity [Req 3.9.3] | +| reference_date | DATE | NULL | Date used for folder structure (e.g. Issue Date) to prevent broken paths | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (uploaded_by_user_id) REFERENCES users(user_id) ON DELETE CASCADE +- INDEX (stored_filename) +- INDEX (mime_type) +- INDEX (uploaded_by_user_id) +- UNIQUE INDEX idx_attachments_uuid (uuid) +- INDEX (created_at) +- INDEX (reference_date) + +**Relationships**: + +- Parent: users +- Referenced by: correspondence_attachments, circulation_attachments, shop_drawing_revision_attachments, contract_drawing_attachments + +**Business Rules**: + +- Central storage prevents file duplication +- Stored filename prevents naming conflicts +- File path points to QNAP NAS storage +- Original filename preserved for download +- One file record can be linked to multiple documents + +--- + +### 8.2 correspondence_attachments + +**Purpose**: Junction table linking correspondences to file attachments (M:N) + +| Column Name | Data Type | Constraints | Description | +| ----------------- | --------- | --------------- | ---------------------------- | +| correspondence_id | INT | PRIMARY KEY, FK | Reference to correspondences | +| attachment_id | INT | PRIMARY KEY, FK | Reference to attachments | +| is_main_document | BOOLEAN | DEFAULT FALSE | Main/primary document flag | + +**Indexes**: + +- PRIMARY KEY (correspondence_id, attachment_id) +- FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE +- INDEX (attachment_id) +- INDEX (is_main_document) + +**Relationships**: + +- Parent: correspondences, attachments + +**Business Rules**: + +- One correspondence can have multiple attachments +- One attachment can be linked to multiple correspondences +- is_main_document identifies primary file (typically PDF) + +--- + +### 8.3 circulation_attachments + +**Purpose**: Junction table linking circulations to file attachments (M:N) + +| Column Name | Data Type | Constraints | Description | +| ---------------- | --------- | --------------- | -------------------------- | +| circulation_id | INT | PRIMARY KEY, FK | Reference to circulations | +| attachment_id | INT | PRIMARY KEY, FK | Reference to attachments | +| is_main_document | BOOLEAN | DEFAULT FALSE | Main/primary document flag | + +**Indexes**: + +- PRIMARY KEY (circulation_id, attachment_id) +- FOREIGN KEY (circulation_id) REFERENCES circulations(id) ON DELETE CASCADE +- FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE +- INDEX (attachment_id) +- INDEX (is_main_document) + +**Relationships**: + +- Parent: circulations, attachments + +--- + +### 8.4 shop_drawing_revision_attachments + +**Purpose**: Junction table linking shop drawing revisions to file attachments (M:N) + +| Column Name | Data Type | Constraints | Description | +| ------------------------ | --------- | --------------- | ---------------------------------- | +| shop_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to shop drawing revision | +| attachment_id | INT | PRIMARY KEY, FK | Reference to attachments | +| file_type | ENUM | NULL | File type: PDF, DWG, SOURCE, OTHER | +| is_main_document | BOOLEAN | DEFAULT FALSE | Main/primary document flag | + +**Indexes**: + +- PRIMARY KEY (shop_drawing_revision_id, attachment_id) +- FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE +- INDEX (attachment_id) +- INDEX (file_type) +- INDEX (is_main_document) + +**Relationships**: + +- Parent: shop_drawing_revisions, attachments + +**Business Rules**: + +- file_type categorizes drawing file formats +- Typically includes PDF for viewing and DWG for editing +- SOURCE may include native CAD files + +--- + +### 8.5 contract_drawing_attachments + +**Purpose**: Junction table linking contract drawings to file attachments (M:N) + +| Column Name | Data Type | Constraints | Description | +| ------------------- | --------- | --------------- | ---------------------------------- | +| contract_drawing_id | INT | PRIMARY KEY, FK | Reference to contract drawing | +| attachment_id | INT | PRIMARY KEY, FK | Reference to attachments | +| file_type | ENUM | NULL | File type: PDF, DWG, SOURCE, OTHER | +| is_main_document | BOOLEAN | DEFAULT FALSE | Main/primary document flag | + +**Indexes**: + +- PRIMARY KEY (contract_drawing_id, attachment_id) +- FOREIGN KEY (contract_drawing_id) REFERENCES contract_drawings(id) ON DELETE CASCADE +- FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE +- INDEX (attachment_id) +- INDEX (file_type) +- INDEX (is_main_document) + +**Relationships**: + +- Parent: contract_drawings, attachments + +--- + +## **9. 🔢 Document Numbering System Tables (ระบบเลขที่เอกสาร)** + +### 9.1 document_number_formats + +**Purpose**: Master table defining numbering formats for each document type + +| Column Name | Data Type | Constraints | Description | +| ---------------------- | ------------ | --------------------------- | -------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique format ID | +| project_id | INT | NOT NULL, FK | Reference to projects | +| correspondence_type_id | INT | NULL, FK | Reference to correspondence_types | +| discipline_id | INT | DEFAULT 0, FK | Reference to disciplines (0 = all) | +| format_string | VARCHAR(100) | NOT NULL | Format pattern (e.g., {ORG}-{TYPE}-{YYYY}-#) | +| description | TEXT | NULL | Format description | +| reset_annually | BOOLEAN | DEFAULT TRUE | Start sequence new every year | +| is_active | TINYINT(1) | DEFAULT 1 | Active status | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE +- UNIQUE KEY (project_id, correspondence_type_id, discipline_id) +- INDEX (is_active) + +**Relationships**: + +- Parent: projects, correspondence_types + +**Business Rules**: + +- Defines how document numbers are constructed +- Supports placeholders: {PROJ}, {ORG}, {TYPE}, {YYYY}, {MM}, {#} + +--- + +### 9.2 document_number_counters (UPDATE v1.7.0) + +**Purpose**: Transaction table tracking running numbers (High Concurrency) + +| Column Name | Data Type | Constraints | Description | +| -------------------------- | ----------- | ------------- | ----------------------------------------------- | +| project_id | INT | PK, NOT NULL | โครงการ | +| originator_organization_id | INT | PK, NOT NULL | องค์กรผู้ส่ง | +| recipient_organization_id | INT | PK, NOT NULL | องค์กรผู้รับ (0 = no recipient / RFA) | +| correspondence_type_id | INT | PK, NULL | ประเภทเอกสาร (NULL = default) | +| sub_type_id | INT | PK, DEFAULT 0 | ประเภทย่อย สำหรับ TRANSMITTAL (0 = ไม่ระบุ) | +| rfa_type_id | INT | PK, DEFAULT 0 | ประเภท RFA (0 = ไม่ใช่ RFA) | +| discipline_id | INT | PK, DEFAULT 0 | สาขางาน (0 = ไม่ระบุ) | +| reset_scope | VARCHAR(20) | PK, NOT NULL | Scope of reset (YEAR_2024, MONTH_2024_01, NONE) | +| last_number | INT | DEFAULT 0 | เลขล่าสุดที่ถูกใช้งานไปแล้ว | +| version | INT | DEFAULT 0 | Optimistic Lock Version | +| updated_at | DATETIME(6) | ON UPDATE | เวลาที่อัปเดตล่าสุด | + +**Indexes**: + +- **PRIMARY KEY (project_id, originator_organization_id, recipient_organization_id, correspondence_type_id, sub_type_id, rfa_type_id, discipline_id, reset_scope)** +- INDEX idx_counter_lookup (project_id, correspondence_type_id, reset_scope) +- INDEX idx_counter_org (originator_organization_id, reset_scope) + +**Business Rules**: + +- **Composite Primary Key 8 Columns**: เพื่อรองรับการรันเลขที่ซับซ้อนและ Reset Scope ที่หลากหลาย +- **Concurrency Control**: ใช้ Redis Lock หรือ Optimistic Locking (version) +- **Reset Scope**: ใช้ Field `reset_scope` ควบคุมการ Reset แทน `current_year` แบบเดิม + +--- + +### 9.3 document_number_audit (UPDATE v1.7.0) + +**Purpose**: Audit log for document number generation (Debugging & Tracking) + +| Column Name | Data Type | Constraints | Description | +| :------------------------- | :----------- | :----------------- | :------------------------------------------ | +| id | INT | PK, AI | ID ของ audit record | +| document_id | INT | NULL, FK | ID ของเอกสารที่สร้างเลขที่ (NULL if failed) | +| document_type | VARCHAR(50) | NULL | ประเภทเอกสาร | +| document_number | VARCHAR(100) | NOT NULL | เลขที่เอกสารที่สร้าง (ผลลัพธ์) | +| operation | ENUM | DEFAULT 'CONFIRM' | RESERVE, CONFIRM, MANUAL_OVERRIDE, etc. | +| status | ENUM | DEFAULT 'RESERVED' | RESERVED, CONFIRMED, CANCELLED, VOID | +| counter_key | JSON | NOT NULL | Counter key ที่ใช้ (JSON 8 fields) | +| reservation_token | VARCHAR(36) | NULL | Token การจอง | +| idempotency_key | VARCHAR(128) | NULL | Idempotency Key from request | +| originator_organization_id | INT | NULL | องค์กรผู้ส่ง | +| recipient_organization_id | INT | NULL | องค์กรผู้รับ | +| template_used | VARCHAR(200) | NOT NULL | Template ที่ใช้ในการสร้าง | +| old_value | TEXT | NULL | Previous value | +| new_value | TEXT | NULL | New value | +| user_id | INT | NULL, FK | ผู้ขอสร้างเลขที่ | +| is_success | BOOLEAN | DEFAULT TRUE | สถานะความสำเร็จ | +| created_at | TIMESTAMP | DEFAULT NOW | วันที่/เวลาที่สร้าง | +| total_duration_ms | INT | NULL | เวลารวมทั้งหมดในการสร้าง (ms) | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (document_id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (user_id) REFERENCES users(user_id) +- INDEX (document_id) +- INDEX (user_id) +- INDEX (status) +- INDEX (operation) +- INDEX (document_number) +- INDEX (reservation_token) +- INDEX (created_at) + +--- + +### 9.4 document_number_errors (UPDATE v1.7.0) + +**Purpose**: Error log for failed document number generation + +| Column Name | Data Type | Constraints | Description | +| :------------ | :-------- | :---------- | :--------------------------------------------- | +| id | INT | PK, AI | ID ของ error record | +| error_type | ENUM | NOT NULL | LOCK_TIMEOUT, VERSION_CONFLICT, DB_ERROR, etc. | +| error_message | TEXT | NULL | ข้อความ error | +| stack_trace | TEXT | NULL | Stack trace สำหรับ debugging | +| context_data | JSON | NULL | Context ของ request | +| user_id | INT | NULL | ผู้ที่เกิด error | +| created_at | TIMESTAMP | DEFAULT NOW | วันที่เกิด error | +| resolved_at | TIMESTAMP | NULL | วันที่แก้ไขแล้ว | + +**Indexes**: + +- PRIMARY KEY (id) +- INDEX (error_type) +- INDEX (created_at) +- INDEX (user_id) +- INDEX (resolved_at) + +--- + +### 9.5 document_number_reservations (NEW v1.7.0) + +**Purpose**: Two-Phase Commit table for document number reservation + +| Column Name | Data Type | Constraints | Description | +| :--------------------- | :----------- | :--------------- | :----------------------------------- | +| id | INT | PK, AI | Unique ID | +| token | VARCHAR(36) | UNIQUE, NOT NULL | UUID v4 Reservation Token | +| document_number | VARCHAR(100) | UNIQUE, NOT NULL | เลขที่เอกสารที่จอง | +| document_number_status | ENUM | DEFAULT RESERVED | RESERVED, CONFIRMED, CANCELLED, VOID | +| document_id | INT | NULL, FK | ID ของเอกสาร (เมื่อ Confirm แล้ว) | +| expires_at | DATETIME(6) | NOT NULL | เวลาหมดอายุการจอง | +| project_id | INT | NOT NULL, FK | Project Context | +| user_id | INT | NOT NULL, FK | User Context | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (document_id) REFERENCES correspondence_revisions(id) ON DELETE SET NULL +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE +- FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE +- INDEX idx_token (token) +- INDEX idx_status (document_number_status) +- INDEX idx_status_expires (document_number_status, expires_at) +- INDEX idx_document_id (document_id) +- INDEX idx_user_id (user_id) +- INDEX idx_reserved_at (reserved_at) + +--- + +## **10. ⚙️ Unified Workflow Engine Tables (UPDATE v1.7.0)** + +### 10.1 workflow_definitions + +**Purpose**: เก็บแม่แบบ (Template) ของ Workflow (Definition / DSL) + +| Column Name | Data Type | Constraints | Description | +| :------------ | :---------- | :----------- | :---------------------------------------- | +| id | CHAR(36) | PK, UUID | Unique Workflow Definition ID | +| workflow_code | VARCHAR(50) | NOT NULL | รหัส Workflow (เช่น RFA_FLOW_V1) | +| version | INT | DEFAULT 1 | หมายเลข Version | +| description | TEXT | NULL | คำอธิบาย Workflow | +| dsl | JSON | NOT NULL | นิยาม Workflow ต้นฉบับ (YAML/JSON Format) | +| compiled | JSON | NOT NULL | โครงสร้าง Execution Tree ที่ Compile แล้ว | +| is_active | BOOLEAN | DEFAULT TRUE | สถานะการใช้งาน | +| created_at | TIMESTAMP | DEFAULT NOW | วันที่สร้าง | +| updated_at | TIMESTAMP | ON UPDATE | วันที่แก้ไขล่าสุด | + +**Indexes**: + +- PRIMARY KEY (id) +- UNIQUE KEY (workflow_code, version) +- INDEX (is_active) + +--- + +### 10.2 workflow_instances + +**Purpose**: เก็บสถานะของ Workflow ที่กำลังรันอยู่จริง (Runtime) + +| Column Name | Data Type | Constraints | Description | +| :------------ | :---------- | :--------------- | :--------------------------------------------- | +| id | CHAR(36) | PK, UUID | Unique Instance ID | +| definition_id | CHAR(36) | FK, NOT NULL | อ้างอิง Definition ที่ใช้ | +| entity_type | VARCHAR(50) | NOT NULL | ประเภทเอกสาร (rfa_revision, correspondence...) | +| entity_id | VARCHAR(50) | NOT NULL | ID ของเอกสาร | +| current_state | VARCHAR(50) | NOT NULL | สถานะปัจจุบัน | +| status | ENUM | DEFAULT 'ACTIVE' | ACTIVE, COMPLETED, CANCELLED, TERMINATED | +| context | JSON | NULL | ตัวแปร Context สำหรับตัดสินใจ | +| created_at | TIMESTAMP | DEFAULT NOW | เวลาที่สร้าง | +| updated_at | TIMESTAMP | ON UPDATE | เวลาที่อัปเดตล่าสุด | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (definition_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE +- INDEX (entity_type, entity_id) +- INDEX (current_state) + +--- + +### 10.3 workflow_histories + +**Purpose**: เก็บประวัติการดำเนินการในแต่ละ Step (Audit Trail) + +| Column Name | Data Type | Constraints | Description | +| :---------------- | :---------- | :----------- | :------------------------ | +| id | CHAR(36) | PK, UUID | Unique ID | +| instance_id | CHAR(36) | FK, NOT NULL | อ้างอิง Instance | +| from_state | VARCHAR(50) | NOT NULL | สถานะต้นทาง | +| to_state | VARCHAR(50) | NOT NULL | สถานะปลายทาง | +| action | VARCHAR(50) | NOT NULL | Action ที่กระทำ | +| action_by_user_id | INT | FK, NULL | User ID ผู้กระทำ | +| comment | TEXT | NULL | ความเห็น | +| metadata | JSON | NULL | Snapshot ข้อมูล ณ ขณะนั้น | +| created_at | TIMESTAMP | DEFAULT NOW | เวลาที่กระทำ | + +**Indexes**: + +- PRIMARY KEY (id) +- FOREIGN KEY (instance_id) REFERENCES workflow_instances(id) ON DELETE CASCADE +- INDEX (instance_id) +- INDEX (action_by_user_id) + +--- + +## **11. 🖥️ System & Logs Tables (ระบบ, บันทึก)** + +> **Audit Logging Architecture:** + +### 1. Audit Logging + +**Table: `audit_logs`** + +บันทึกการเปลี่ยนแปลงสำคัญ: + +- User actions (CREATE, UPDATE, DELETE) +- Entity type และ Entity ID +- Old/New values (JSON) +- IP Address, User Agent + +### 2. User Preferences + +**Table: `user_preferences`** + +เก็บการตั้งค่าส่วนตัว: + +- Language preference +- Notification settings +- UI preferences (JSON) + +### 3. JSON Schema Validation + +**Table: `json_schemas`** + +เก็บ Schema สำหรับ Validate JSON fields: + +- `correspondence_revisions.details` +- `user_preferences.preferences` + +--- + +### 11.1 json_schemas (UPDATE v1.7.0) + +**Purpose**: เก็บ Schema สำหรับ Validate JSON Columns (Req 3.12) + +| Column Name | Data Type | Constraints | Description | +| :---------------- | :----------- | :----------- | :---------------------------------- | +| id | INT | PK, AI | Unique ID | +| schema_code | VARCHAR(100) | NOT NULL | รหัส Schema (เช่น RFA_DWG) | +| version | INT | DEFAULT 1 | เวอร์ชันของ Schema | +| table_name | VARCHAR(100) | NOT NULL | ชื่อตารางเป้าหมาย | +| schema_definition | JSON | NOT NULL | JSON Schema Definition | +| ui_schema | JSON | NULL | โครงสร้าง UI Schema สำหรับ Frontend | +| virtual_columns | JSON | NULL | Config สำหรับสร้าง Virtual Columns | +| migration_script | JSON | NULL | Script สำหรับแปลงข้อมูล | +| is_active | BOOLEAN | DEFAULT TRUE | สถานะการใช้งาน | + +--- + +### 11.2 audit_logs (UPDATE v1.7.0) + +**Purpose**: Centralized audit logging for all system actions (Req 6.1) + +| Column Name | Data Type | Constraints | Description | +| :----------- | :----------- | :------------------------ | :------------------------------------------- | +| audit_id | BIGINT | PK, AI | Unique log ID | +| request_id | VARCHAR(100) | NULL | Trace ID linking to app logs | +| user_id | INT | NULL, FK | User who performed action | +| action | VARCHAR(100) | NOT NULL | Action name (e.g. rfa.create) | +| severity | ENUM | DEFAULT 'INFO' | INFO, WARN, ERROR, CRITICAL | +| entity_type | VARCHAR(50) | NULL | Module/Table name (e.g. rfa, correspondence) | +| entity_id | VARCHAR(50) | NULL | ID of affected entity | +| details_json | JSON | NULL | Context data / Old & New values | +| ip_address | VARCHAR(45) | NULL | User IP address | +| user_agent | VARCHAR(255) | NULL | User browser/client info | +| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Log timestamp | + +**Indexes**: + +- PRIMARY KEY (audit_id, created_at) -- **Partition Key** +- INDEX idx_audit_user (user_id) +- INDEX idx_audit_action (action) +- INDEX idx_audit_entity (entity_type, entity_id) +- INDEX idx_audit_created (created_at) + +**Partitioning**: + +- **PARTITION BY RANGE (YEAR(created_at))**: แบ่ง Partition รายปี เพื่อประสิทธิภาพในการเก็บข้อมูลระยะยาว + +--- + +### 11.3 notifications (UPDATE v1.7.0) + +**Purpose**: System notifications for users + +| Column Name | Data Type | Constraints | Description | +| :---------------- | :----------- | :-------------------------- | :------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique notification ID | +| uuid | UUID | NOT NULL, DEFAULT | UUID Public Identifier (ADR-019) | +| user_id | INT | NOT NULL, FK | Recipient user ID | +| title | VARCHAR(255) | NOT NULL | Notification title | +| message | TEXT | NOT NULL | Notification body | +| notification_type | ENUM | NOT NULL | Type: EMAIL, LINE, SYSTEM | +| is_read | BOOLEAN | DEFAULT FALSE | Read status | +| entity_type | VARCHAR(50) | NULL | Related Entity Type | +| entity_id | INT | NULL | Related Entity ID | +| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Notification timestamp | + +**Indexes**: + +- PRIMARY KEY (id, created_at) -- **Partition Key** +- INDEX idx_notif_user (user_id) +- INDEX idx_notif_type (notification_type) +- INDEX idx_notif_read (is_read) +- INDEX idx_notif_created (created_at) +- INDEX idx_notifications_uuid (uuid) + +**Partitioning**: + +- **PARTITION BY RANGE (YEAR(created_at))**: แบ่ง Partition รายปี + +--- + +## **12. 🔍 Views (มุมมองข้อมูล)** + +### 12.1 v_current_correspondences + +**Purpose**: แสดงข้อมูล Correspondence Revision ล่าสุด (is_current = TRUE) + +### 12.2 v_current_rfas + +**Purpose**: แสดงข้อมูล RFA Revision ล่าสุด พร้อม Status และ Approve Code + +### 12.3 v_user_tasks (Unified Workflow) + +**Purpose**: รวมรายการงานที่ยังค้างอยู่ (Status = ACTIVE) จากทุกระบบ (RFA, Circulation, Correspondence) เพื่อนำไปแสดงใน Dashboard + +### 12.4 v_audit_log_details + +**Purpose**: แสดง audit_logs พร้อมข้อมูล username และ email ของผู้กระทำ + +### 12.5 v_user_all_permissions + +**Purpose**: รวมสิทธิ์ทั้งหมด (Global + Project + Organization) ของผู้ใช้ทุกคน + +### 12.6 v_documents_with_attachments + +**Purpose**: แสดงเอกสารทั้งหมดที่มีไฟล์แนบ (Correspondence, Circulation, Drawings) + +### 12.7 v_document_statistics + +**Purpose**: แสดงสถิติเอกสารตามประเภทและสถานะ + +--- + +## **13. 📊 Index Summaries (สรุป Index)** + +> **Performance Optimization Strategy:** + +### 1. Indexing Strategy + +**Primary Indexes:** + +- Primary Keys (AUTO_INCREMENT) +- Foreign Keys (automatic in InnoDB) +- Unique Constraints (business keys) + +**Secondary Indexes:** + +```sql +-- Correspondence search +CREATE INDEX idx_corr_type_status ON correspondence_revisions(correspondence_type_id, correspondence_status_id); +CREATE INDEX idx_corr_date ON correspondence_revisions(document_date); + +-- Virtual columns for JSON +CREATE INDEX idx_v_ref_project ON correspondence_revisions(v_ref_project_id); +CREATE INDEX idx_v_doc_subtype ON correspondence_revisions(v_doc_subtype); + +-- User lookup +CREATE INDEX idx_user_email ON users(email); +CREATE INDEX idx_user_org ON users(primary_organization_id, is_active); +``` + +### 2. Virtual Columns + +ใช้ Virtual Columns สำหรับ Index JSON fields: + +```sql +ALTER TABLE correspondence_revisions +ADD COLUMN v_ref_project_id INT GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(details, '$.ref_project_id'))) VIRTUAL, +ADD INDEX idx_v_ref_project(v_ref_project_id); +``` + +### 3. Partitioning (Future) + +พิจารณา Partition ตาราง `audit_logs` ตามปี: + +```sql +ALTER TABLE audit_logs +PARTITION BY RANGE (YEAR(created_at)) ( + PARTITION p2024 VALUES LESS THAN (2025), + PARTITION p2025 VALUES LESS THAN (2026), + PARTITION p_future VALUES LESS THAN MAXVALUE +); +``` + +--- + +### 13.1 Performance Indexes + +| Table Name | Index Columns | Purpose | +| :----------------------- | :------------------------------------------------ | :----------------------------- | +| correspondences | (project_id, correspondence_number) | Fast lookup by document number | +| correspondences | (correspondence_type_id) | Filter by type | +| correspondence_revisions | (correspondence_id, is_current) | Get current revision | +| rfas | (rfa_type_id) | Filter by RFA type | +| rfa_revisions | (rfa_id, is_current) | Get current RFA revision | +| rfa_revisions | (rfa_status_code_id) | Filter by status | +| audit_logs | (created_at) | Date range queries | +| audit_logs | (user_id) | User activity history | +| audit_logs | (module, action) | Action type analysis | +| notifications | (user_id, is_read) | Unread notifications query | +| document_number_counters | (project_id, correspondence_type_id, reset_scope) | Running number generation | +| workflow_instances | (entity_type, entity_id) | Workflow lookup by document ID | +| workflow_instances | (current_state) | Monitor active workflows | + +### 13.2 Unique Constraints + +| Table Name | Columns | Description | +| :---------------------- | :----------------------------------- | :--------------------------------- | +| users | (username) | Unique login name | +| users | (email) | Unique email address | +| organizations | (organization_code) | Unique organization code | +| projects | (project_code) | Unique project code | +| contracts | (contract_code) | Unique contract code | +| correspondences | (project_id, correspondence_number) | Unique document number per project | +| shop_drawings | (drawing_number) | Unique shop drawing number | +| document_number_formats | (project_id, correspondence_type_id) | One format per type per project | +| workflow_definitions | (workflow_code, version) | Unique workflow code per version | + +--- + +## **14. 🛡️ Data Integrity Constraints (ความถูกต้องของข้อมูล)** + +### 14.1 Soft Delete Policy + +- **Tables with `deleted_at`**: + - users + - organizations + - projects + - contracts + - correspondences + - rfas + - shop_drawings + - contract_drawings +- **Rule**: Records are never physically deleted. `deleted_at` is set to timestamp. +- **Query Rule**: All standard queries MUST include `WHERE deleted_at IS NULL`. + +### 14.2 Foreign Key Cascades + +- **ON DELETE CASCADE**: + - Used for child tables that cannot exist without parent (e.g., `correspondence_revisions`, `rfa_revisions`, `correspondence_attachments`). +- **ON DELETE RESTRICT**: + - Used for master data references to prevent accidental deletion of used data (e.g., `correspondence_types`, `organizations`). +- **ON DELETE SET NULL**: + - Used for optional references (e.g., `created_by`, `originator_id`). + +--- + +## **15. 🔐 Security & Permissions Model (ความปลอดภัย)** + +### 15.1 Row-Level Security (RLS) Logic + +- **Organization Scope**: Users can only see documents where `originator_id` OR `recipient_organization_id` matches their organization. +- **Project Scope**: Users can only see documents within projects they are assigned to. +- **Confidentiality**: Documents marked `is_confidential` are visible ONLY to specific roles or users. + +### 15.2 Role-Based Access Control (RBAC) + +- **Permissions** are granular (e.g., `correspondence.view`, `correspondence.create`). +- **Roles** aggregate permissions (e.g., `Document Controller` = `view` + `create` + `edit`). +- **Assignments** link Users to Roles within a Context (Global, Project, or Organization). + +--- + +## **16. 🔄 Data Migration & Seeding (การย้ายข้อมูล)** + +### 16.1 Initial Seeding (V1.7.0) + +1. **Master Data**: + - `organizations`: Owner, Consultant, Contractor + - `projects`: LCBP3 + - `correspondence_types`: LETTER, MEMO, TRANSMITTAL, RFA + - `rfa_types`: DWG, MAT, DOC, RFI + - `rfa_status_codes`: DFT, PEND, APPR, REJ + - `disciplines`: GEN, STR, ARC, MEP +2. **System Users**: + - `admin`: Super Admin + - `system`: System Bot for automated tasks + +### 16.2 Migration Strategy + +- **Schema Migration**: Use TypeORM Migrations or raw SQL scripts (versioned). +- **Data Migration**: + - **V1.6.0 -> V1.7.0**: + - Run SQL script `9_lcbp3_v1_7_0.sql` + - Migrate `document_number_counters` to 8-col composite PK. + - Initialize `document_number_reservations`. + - Update `json_schemas` with new columns. + +--- + +### 16.3 Temporary Migration Tracking Tables (V1.8.0 n8n Migration) + +ตารางเหล่านี้ถูกใช้ชั่วคราวระหว่างกระบวนการ Migrate เอกสาร PDF 20,000 ฉบับด้วย n8n (ดูรายละเอียดใน 3-05-n8n-migration-setup-guide.md) และไม่ใช่ตาราง Business หลักของระบบ + +#### 16.3.1 migration_progress + +**Purpose**: เก็บ Checkpoint สถานะการ Migrate + +| Column Name | Data Type | Constraints | Description | +| :------------------- | :---------- | :---------------------------------- | :--------------------------------- | +| batch_id | VARCHAR(50) | PRIMARY KEY | รหัสชุดการ Migrate | +| last_processed_index | INT | DEFAULT 0 | ลำดับล่าสุดที่ประมวลผลผ่าน | +| status | ENUM | DEFAULT 'RUNNING' | สถานะ (RUNNING, COMPLETED, FAILED) | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | เวลาอัปเดตล่าสุด | + +#### 16.3.2 migration_review_queue + +**Purpose**: คิวเอกสารที่ต้องการให้เจ้าหน้าที่ตรวจสอบ (Confidence ต่ำกว่าเกณฑ์) +_หมายเหตุ: เมื่อตรวจสอบผ่านและสร้าง Correspondence จริงแล้ว ข้อมูลในนี้อาจถูกลบหรือเก็บเป็น Log ได้_ + +| Column Name | Data Type | Constraints | Description | +| :-------------------- | :----------- | :-------------------------- | :----------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique ID | +| document_number | VARCHAR(100) | NOT NULL, UNIQUE | เลขที่เอกสาร (จาก OCR) | +| title | TEXT | | ชื่อเรื่อง | +| original_title | TEXT | | ชื่อเรื่องต้นฉบับก่อนตรวจสอบ | +| ai_suggested_category | VARCHAR(50) | | หมวดหมู่ที่ AI แนะนำ | +| ai_confidence | DECIMAL(4,3) | | ค่าความมั่นใจของ AI (0.000 - 1.000) | +| ai_issues | JSON | | รายละเอียดปัญหาที่ AI พบ | +| review_reason | VARCHAR(255) | | เหตุผลที่ต้องตรวจสอบ (เช่น Confidence ต่ำ) | +| status | ENUM | DEFAULT 'PENDING' | PENDING, APPROVED, REJECTED | +| reviewed_by | VARCHAR(100) | | ผู้ตรวจสอบ | +| reviewed_at | TIMESTAMP | NULL | เวลาที่ตรวจสอบ | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | วันที่บันทึกเข้าคิว | + +#### 16.3.3 migration_errors + +**Purpose**: บันทึกข้อผิดพลาด (Errors) ระหว่างการทำงานของ n8n workflow + +| Column Name | Data Type | Constraints | Description | +| :-------------- | :----------- | :-------------------------- | :-------------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique ID | +| batch_id | VARCHAR(50) | INDEX | รหัสชุดการ Migrate | +| document_number | VARCHAR(100) | | เลขที่เอกสาร | +| error_type | ENUM | INDEX | ประเภท Error (FILE_NOT_FOUND, AI_PARSE_ERROR, etc.) | +| error_message | TEXT | | รายละเอียด Error | +| raw_ai_response | TEXT | | Raw response จาก AI กรณีแปลผลไม่ได้ | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | วันที่บันทึก | + +#### 16.3.4 migration_fallback_state + +**Purpose**: ติดตามสถานะ Fallback ของ AI (เช่น เปลี่ยน Model เมื่อ Error ถี่) + +| Column Name | Data Type | Constraints | Description | +| :----------------- | :---------- | :---------------------------------- | :---------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique ID | +| batch_id | VARCHAR(50) | UNIQUE | รหัสชุดการ Migrate | +| recent_error_count | INT | DEFAULT 0 | จำนวน Error รวดล่าสุด | +| is_fallback_active | BOOLEAN | DEFAULT FALSE | สถานะการใช้งาน Fallback Model | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | เวลาอัปเดตล่าสุด | + +#### 16.3.5 import_transactions + +**Purpose**: ป้องกันข้อมูลซ้ำ (Idempotency) ระหว่างการ Patch ข้อมูล + +| Column Name | Data Type | Constraints | Description | +| :-------------- | :----------- | :-------------------------- | :------------------------ | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique ID | +| idempotency_key | VARCHAR(255) | UNIQUE, NOT NULL | Key สำหรับเช็คซ้ำ | +| document_number | VARCHAR(100) | | เลขที่เอกสาร | +| batch_id | VARCHAR(100) | | รหัสชุดการ Migrate | +| status_code | INT | DEFAULT 201 | HTTP Status ของการ Import | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | วันที่บันทึก | + +#### 16.3.6 migration_daily_summary + +**Purpose**: สรุปยอดการทำงานรายวันแยกตาม Batch + +| Column Name | Data Type | Constraints | Description | +| :-------------- | :---------- | :-------------------------- | :------------------------ | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique ID | +| batch_id | VARCHAR(50) | UNIQUE KEY PART 1 | รหัสชุดการ Migrate | +| summary_date | DATE | UNIQUE KEY PART 2 | วันที่สรุป | +| total_processed | INT | DEFAULT 0 | จำนวนที่ประมวลผลรวม | +| auto_ingested | INT | DEFAULT 0 | จำนวนที่เข้าสู่ระบบสำเร็จ | +| sent_to_review | INT | DEFAULT 0 | จำนวนที่ส่งคิวตรวจสอบ | +| rejected | INT | DEFAULT 0 | จำนวนที่ถูกปฏิเสธ | +| errors | INT | DEFAULT 0 | จำนวนที่เกิด Error | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | วันที่บันทึก | + +--- + +## **17. 📈 Monitoring & Maintenance (การดูแลรักษา)** + +### 17.1 Database Maintenance + +- **Daily**: Incremental Backup. +- **Weekly**: Full Backup + `OPTIMIZE TABLE` for heavy tables (`audit_logs`, `notifications`). +- **Monthly**: Archive old `audit_logs` partitions to cold storage. + +### 17.2 Health Checks + +- Monitor `document_number_errors` for numbering failures. +- Monitor `workflow_instances` for stuck workflows (`status = ' IN_PROGRESS '` > 7 days). +- Check `document_number_counters` for gaps or resets. + +--- + +## **18. 📚 Best Practices** + +### 1. Naming Conventions + +- **Tables**: `snake_case`, plural (e.g., `correspondences`, `users`) +- **Columns**: `snake_case` (e.g., `correspondence_number`, `created_at`) +- **Foreign Keys**: `{referenced_table_singular}_id` (e.g., `project_id`, `user_id`) +- **Junction Tables**: `{table1}_{table2}` (e.g., `correspondence_tags`) + +### 2. Timestamp Columns + +ทุกตารางควรมี: + +- `created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` +- `updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` + +### 3. Soft Delete + +ใช้ `deleted_at DATETIME NULL` แทนการลบจริง: + +```sql +-- Soft delete +UPDATE correspondences SET deleted_at = NOW() WHERE id = 1; + +-- Query active records +SELECT * FROM correspondences WHERE deleted_at IS NULL; +``` + +### 4. JSON Field Guidelines + +- ใช้สำหรับข้อมูลที่ไม่ต้อง Query บ่อย +- สร้าง Virtual Columns สำหรับ fields ที่ต้อง Index +- Validate ด้วย JSON Schema +- Document structure ใน Data Dictionary + +--- + +--- + +## **19. 📖 Glossary (คำศัพท์)** + +- **RFA**: Request for Approval (เอกสารขออนุมัติ) +- **Transmittal**: Document Transmittal Sheet (ใบนำส่งเอกสาร) +- **Shop Drawing**: แบบก่อสร้างที่ผู้รับเหมาจัดทำ +- **Contract Drawing**: แบบสัญญา (แบบตั้งต้น) +- **Revision**: ฉบับแก้ไข (0, 1, 2, A, B, C) +- **Originator**: ผู้จัดทำ/ผู้ส่งเอกสาร +- **Recipient**: ผู้รับเอกสาร +- **Workflow**: กระบวนการทำงาน/อนุมัติ +- **Discipline**: สาขางาน (เช่น โยธา, สถาปัตย์, ไฟฟ้า) + +--- + +**End of Data Dictionary V1.8.0** diff --git a/specs/03-Data-and-Storage/03-02-db-indexing.md b/specs/03-Data-and-Storage/03-02-db-indexing.md index bc4d4ff..1f171d9 100644 --- a/specs/03-Data-and-Storage/03-02-db-indexing.md +++ b/specs/03-Data-and-Storage/03-02-db-indexing.md @@ -1,10 +1,13 @@ # Database Indexing & Performance Strategy + **Version:** 1.0.0 **Context:** Production-scale (100k+ documents, High Concurrency) **Database:** MySQL 8.x (On-Premise via Docker) ## 1. Core Principles (หลักการสำคัญ) + ในการออกแบบ Database Index สำหรับระบบ DMS ให้ยึดหลักการตัดสินใจดังนี้: + 1. **Data Integrity First:** ใช้ `UNIQUE INDEX` เพื่อเป็นปราการด่านสุดท้ายป้องกันการเกิด Duplicate Document Number และ Revision ซ้ำซ้อน (แม้ Application Layer จะมี Logic ดักไว้แล้วก็ตาม) 2. **Soft-Delete Awareness:** ทุก Index ที่เกี่ยวข้องกับความถูกต้องของข้อมูล ต้องคำนึงถึงคอลัมน์ `deleted_at` เพื่อไม่ให้เอกสารที่ถูกลบไปแล้ว มาขัดขวางการสร้างเอกสารใหม่ที่ใช้เลขเดิม 3. **Foreign Key Performance:** สร้าง B-Tree Index ให้กับ Foreign Key (FK) ทุกตัว เพื่อรองรับการ JOIN ข้อมูลที่รวดเร็ว โดยเฉพาะการดึง Workflow และ Routing @@ -13,12 +16,15 @@ --- ## 2. Document Control Indexes (ป้องกัน Duplicate & Conflict) + หัวใจของ DMS คือห้ามมีเอกสารเลขซ้ำในระบบที่ Active อยู่ ### 2.1 Unique Document Number & Revision + เพื่อรองรับระบบ Soft Delete (`deleted_at`) ใน MySQL การตั้ง Unique Index จำเป็นต้องมีเทคนิคเพื่อจัดการกับค่า `NULL` (เนื่องจาก MySQL มองว่า `NULL` ไม่เท่ากับ `NULL` จึงอาจทำให้เกิด Duplicate ได้ถ้าตั้งค่าไม่รัดกุม) **SQL Recommendation (Functional Index - MySQL 8.0+):** + ```sql -- ป้องกันการสร้าง Document No และ Revision ซ้ำ สำหรับเอกสารที่ยังไม่ถูกลบ (Active) ALTER TABLE `documents` @@ -30,7 +36,7 @@ ADD UNIQUE INDEX `idx_unique_active_doc_rev` ( ``` -*เหตุผล:* โครงสร้างนี้รับประกันว่าจะมี `document_no` + `revision` ที่ Active ได้เพียง 1 รายการเท่านั้น แต่สามารถมีรายการที่ถูกลบ (`deleted_at` มีค่า) ซ้ำกันได้ +_เหตุผล:_ โครงสร้างนี้รับประกันว่าจะมี `document_no` + `revision` ที่ Active ได้เพียง 1 รายการเท่านั้น แต่สามารถมีรายการที่ถูกลบ (`deleted_at` มีค่า) ซ้ำกันได้ ### 2.2 Current/Superseded Flag Index @@ -75,7 +81,7 @@ ADD FULLTEXT INDEX `ft_idx_doc_title` (`title`, `subject`); ``` -*(หมายเหตุ: หากอนาคตมีระบบ OCR หรือค้นหาในเนื้อหาไฟล์ PDF ให้พิจารณาขยับไปใช้ Elasticsearch แยกต่างหาก ไม่ควรเก็บ Full-Text ขนาดใหญ่ไว้ใน MySQL)* +_(หมายเหตุ: หากอนาคตมีระบบ OCR หรือค้นหาในเนื้อหาไฟล์ PDF ให้พิจารณาขยับไปใช้ Elasticsearch แยกต่างหาก ไม่ควรเก็บ Full-Text ขนาดใหญ่ไว้ใน MySQL)_ --- @@ -104,10 +110,9 @@ ADD INDEX `idx_audit_user_action` (`user_id`, `action`, `created_at`); 1. **Partitioning:** แนะนำให้ทำ Table Partitioning ตามเดือน (Monthly) หรือปี (Yearly) บนคอลัมน์ `created_at` 2. **Minimal Indexing:** ห้ามสร้าง Index เยอะเกินความจำเป็นในตารางนี้ แนะนำแค่: -* `INDEX(document_id, created_at)` สำหรับดู History ของเอกสารนั้นๆ -* `INDEX(user_id, created_at)` สำหรับตรวจสอบพฤติกรรมผู้ใช้ต้องสงสัย (Security Audit) - +- `INDEX(document_id, created_at)` สำหรับดู History ของเอกสารนั้นๆ +- `INDEX(user_id, created_at)` สำหรับตรวจสอบพฤติกรรมผู้ใช้ต้องสงสัย (Security Audit) ```sql -- ตัวอย่างการ Index สำหรับดูกระแสของเอกสาร @@ -122,10 +127,10 @@ ADD INDEX `idx_entity_history` (`entity_type`, `entity_id`, `created_at` DESC); เนื่องจากระบบอยู่บน On-Prem NAS (QNAP/ASUSTOR) ทรัพยากร I/O ของดิสก์มีจำกัด (Disk IOPS) -* **Index Defragmentation:** ให้กำหนด Scheduled Task (ผ่าน Cronjob หรือ MySQL Event) มารัน `OPTIMIZE TABLE` ทุกๆ ไตรมาส สำหรับตารางที่มีการ Delete/Update บ่อย (ช่วยคืนพื้นที่ดิสก์และลด I/O) -* **Slow Query Monitoring:** ใน `04-infrastructure-ops/04-01-docker-compose.md` ต้องเปิดใช้งาน `slow_query_log=1` และตั้ง `long_query_time=2` เพื่อตรวจสอบว่ามี Query ใดทำงานแบบ Full Table Scan (ไม่ใช้ Index) หรือไม่ - +- **Index Defragmentation:** ให้กำหนด Scheduled Task (ผ่าน Cronjob หรือ MySQL Event) มารัน `OPTIMIZE TABLE` ทุกๆ ไตรมาส สำหรับตารางที่มีการ Delete/Update บ่อย (ช่วยคืนพื้นที่ดิสก์และลด I/O) +- **Slow Query Monitoring:** ใน `04-infrastructure-ops/04-01-docker-compose.md` ต้องเปิดใช้งาน `slow_query_log=1` และตั้ง `long_query_time=2` เพื่อตรวจสอบว่ามี Query ใดทำงานแบบ Full Table Scan (ไม่ใช้ Index) หรือไม่ ## 💡 คำแนะนำเพิ่มเติมจาก Architect (Architect's Notes): + 1. **เรื่อง Soft Delete กับ Unique Constraint:** เป็นจุดที่นักพัฒนาพลาดกันบ่อยที่สุด ถ้าระบบอนุญาตให้ลบ `DOC-001 Rev.0` แล้วสร้าง `DOC-001 Rev.0` ใหม่ได้ การจัดการ Unique Constraint บน MySQL ต้องใช้ Functional Index (ตามตัวอย่างในข้อ 2.1) เพื่อป้องกันการตีกันของค่า `NULL` ในฐานข้อมูล 2. **ลดภาระ QNAP/ASUSTOR:** อุปกรณ์จำพวก NAS On-Premise มักจะมีปัญหาเรื่อง Random Read/Write Disk I/O การใช้ **Composite Index** แบบครบคลุม (Covering Index) จะช่วยให้ MySQL คืนค่าได้จาก Index Tree โดยตรง ไม่ต้องกระโดดไปอ่าน Data File จริง ซึ่งจะช่วยรีด Performance ของ NAS ได้สูงสุดครับ diff --git a/specs/03-Data-and-Storage/03-03-file-storage.md b/specs/03-Data-and-Storage/03-03-file-storage.md index 288ddc1..2892adf 100644 --- a/specs/03-Data-and-Storage/03-03-file-storage.md +++ b/specs/03-Data-and-Storage/03-03-file-storage.md @@ -1,16 +1,19 @@ # 3.3 File Storage and Handling --- + title: 'Data & Storage: File Storage and Handling (Two-Phase)' version: 1.8.0 status: drafted owner: Nattanin Peancharoen last_updated: 2026-02-22 related: + - specs/01-requirements/01-03.10-file-handling.md (Merged) - specs/03-Data-and-Storage/ADR-003-file-storage-approach.md (Merged) - specs/02-architecture/02-01-system-architecture.md - ADR-006-security-best-practices + --- ## 1. Overview and Core Infrastructure Requirements @@ -18,12 +21,15 @@ related: เอกสารฉบับนี้รวบรวมข้อกำหนดการจัดการไฟล์และการจัดเก็บไฟล์ (File Storage Approach) สำหรับ LCBP3-DMS โดยมีข้อบังคับด้าน Infrastructure และ Security ที่สำคัญมากดังต่อไปนี้: ### 1.1 Infrastructure Requirement (การจัดเก็บและ Mount Volume) + **สำคัญ (CRITICAL SPECIFICATION):** + 1. **Outside Webroot:** ไฟล์รูปและเอกสารทั้งหมดต้องถูกจัดเก็บไว้ **ภายนอก Webroot ของ Application** ห้ามเก็บไฟล์รูปหรือเอกสารไว้ใน Container หรือโฟลเดอร์ Webroot เด็ดขาด เพื่อป้องกันการเข้าถึงไฟล์โดยตรงจากสาธารณะ (Direct Public Access) 2. **QNAP Volume Mount:** ต้องใช้ **QNAP Volume Mount เข้า Docker** (Mount external volume from QNAP NAS to Docker container) สำหรับเป็นพื้นที่เก็บไฟล์ Storage ให้ Container ดึงไปใช้งาน 3. **Authenticated Endpoint:** ไฟล์ต้องถูกเข้าถึงและให้บริการดาวน์โหลดผ่าน Authenticated Endpoint ในฝั่ง Backend เท่านั้น โดยต้องผ่านการตรวจสอบสิทธิ์ (RBAC / Junction Table) เสียก่อน ### 1.2 Access & Security Rules + - **Virus Scan:** ต้องมีการ scan virus สำหรับไฟล์ที่อัปโหลดทั้งหมด โดยใช้ ClamAV หรือบริการ third-party ก่อนการบันทึก - **Whitelist File Types:** อนุญาตเฉพาะเอกสารตามที่กำหนด: PDF, DWG, DOCX, XLSX, ZIP - **Max File Size:** ขนาดไฟล์สูงสุดไม่เกิน 50MB ต่อไฟล์ (Total max 500MB per form submission) @@ -36,7 +42,9 @@ related: ## 2. Two-Phase File Storage Approach (ADR-003) ### 2.1 Context and Problem Statement + LCBP3-DMS ต้องจัดการ File Uploads สำหรับ Attachments ของเอกสาร (PDF, DWG, DOCX, etc.) โดยต้องรับมือกับปัญหา: + 1. **Orphan Files:** User อัปโหลดไฟล์แล้วไม่ Submit Form ทำให้ไฟล์ค้างใน Storage 2. **Transaction Integrity:** ถ้า Database Transaction Rollback ไฟล์ยังอยู่ใน Storage ต้องสอดคล้องกับ Database Record 3. **Virus Scanning:** ต้อง Scan ไฟล์ก่อน Save เข้าระบบถาวร @@ -44,6 +52,7 @@ LCBP3-DMS ต้องจัดการ File Uploads สำหรับ Attachm 5. **Storage Organization:** จัดเก็บไฟล์แยกเป็นสัดส่วน (เพื่อไม่ให้ QNAP Storage กระจัดกระจายและจำกัดขนาดได้) ### 2.2 Decision Drivers + - **Data Integrity:** File และ Database Record ต้อง Consistent - **Security:** ป้องกัน Virus และ Malicious Files - **User Experience:** Upload ต้องรวดเร็ว ไม่ Block UI (ถ้าอัปโหลดพร้อม Submit อาจทำให้ระบบดูค้าง) @@ -51,11 +60,13 @@ LCBP3-DMS ต้องจัดการ File Uploads สำหรับ Attachm - **Auditability:** ติดตามประวัติ File Operations ได้ ### 2.3 Considered Options & Decision + - **Option 1:** Direct Upload to Permanent Storage (ทิ้งไฟล์ถ้า Transaction Fail / ได้ Orphan Files) - ❌ - **Option 2:** Upload after Form Submission (UX แย่ ผู้ใช้ต้องรออัปโหลดรวดเดียวท้ายสุด) - ❌ - **Option 3: Two-Phase Storage (Temp → Permanent) ⭐ (Selected Option)** - ✅ **แนวทาง Two-Phase Storage (Temp → Permanent):** + 1. **Phase 1 (Upload):** ไฟล์ถูกอัปโหลดเข้าโฟลเดอร์ `temp/` ได้รับ `temp_id` 2. **Phase 2 (Commit):** เมื่อ User กด Submit ฟอร์มสำเร็จ ระบบจะย้ายไฟล์จาก `temp/` ไปยัง `permanent/{YYYY}/{MM}/` และบันทึกลง Database ใน Transaction เดียวกัน 3. **Cleanup:** มี Cron Job ทำหน้าที่ลบไฟล์ใน `temp/` ที่ค้างเกินกำหนด (เช่น 24 ชั่วโมง) @@ -65,6 +76,7 @@ LCBP3-DMS ต้องจัดการ File Uploads สำหรับ Attachm ## 3. Implementation Details ### 3.1 Database Schema + ```sql CREATE TABLE attachments ( id INT PRIMARY KEY AUTO_INCREMENT, @@ -89,6 +101,7 @@ CREATE TABLE attachments ( ``` ### 3.2 Two-Phase Storage Flow + ```mermaid sequenceDiagram participant User as Client @@ -205,7 +218,12 @@ export class FileStorageService { } // Phase 2: Commit to Permanent (within Transaction Manager) - async commitFiles(tempIds: string[], entityId: number, entityType: string, manager: EntityManager): Promise { + async commitFiles( + tempIds: string[], + entityId: number, + entityType: string, + manager: EntityManager + ): Promise { const attachments = []; for (const tempId of tempIds) { @@ -227,13 +245,17 @@ export class FileStorageService { await fs.move(tempAttachment.file_path, permanentPath); // Update Database record - await manager.update(Attachment, { id: tempAttachment.id }, { - file_path: permanentPath, - stored_filename: permanentFilename, - is_temporary: false, - temp_id: null, - expires_at: null, - }); + await manager.update( + Attachment, + { id: tempAttachment.id }, + { + file_path: permanentPath, + stored_filename: permanentFilename, + is_temporary: false, + temp_id: null, + expires_at: null, + } + ); attachments.push(tempAttachment); } @@ -283,7 +305,9 @@ export class FileStorageService { ``` ### 3.4 API Controller Context + ในส่วนของตัว Controller ฝ่ายรับข้อมูลจะต้องแยกระหว่าง Uploading กับ Comit: + 1. `POST /attachments/upload` ใช้เพื่อรับไฟล์และ Return `temp_id` แก่ User ทันที 2. `POST /correspondences` หรือ Object อื่นๆ ใช้เพื่อ Commit Database โดยจะรับ `temp_file_ids: []` พ่วงมากับ Body form @@ -292,6 +316,7 @@ export class FileStorageService { ## 4. Consequences & Mitigation Strategies ### Positive Consequences + 1. ✅ **Fast Upload UX:** User upload แบบ Async ก่อน Submit ดำเนินการลื่นไหล 2. ✅ **No Orphan Files:** เกิดระบบ Auto-cleanup จัดการไฟล์หมดอายุโดยอัตโนมัติ ไม่เปลืองสเปซ QNAP 3. ✅ **Transaction Safe:** Rollback ได้สมบูรณ์หากบันทึกฐานข้อมูลผิดพลาด ไฟล์จะถูก Cron จัดการให้ทีหลังไม่ตกค้างในระบบ @@ -300,16 +325,18 @@ export class FileStorageService { 6. ✅ **Storage Organization:** จัดเก็บอย่างเป็นระเบียบ ด้วยรูปแบบ YYYY/MM ลดคอขวด IO Operations ในระบบ ### Negative Consequences & Mitigations + 1. ❌ **Complexity:** ต้อง Implement 2 phases ซึ่งซับซ้อนขึ้น - 👉 *Mitigation:* รวบ Logic ทุกอย่างให้เป็น Service ชั้นเดียว (`FileStorageService`) เพื่อให้จัดการง่ายและเรียกใช้ง่ายที่สุด + 👉 _Mitigation:_ รวบ Logic ทุกอย่างให้เป็น Service ชั้นเดียว (`FileStorageService`) เพื่อให้จัดการง่ายและเรียกใช้ง่ายที่สุด 2. ❌ **Extra Storage:** ต้องใช้พื้นที่ QNAP ในส่วน Temp directory ควบคู่ไปกับแบบ Permanent - 👉 *Mitigation:* คอย Monitor และปรับรอบความถี่ของการ Cleanup หากไฟล์มีปริมาณไหลเวียนเยอะมาก + 👉 _Mitigation:_ คอย Monitor และปรับรอบความถี่ของการ Cleanup หากไฟล์มีปริมาณไหลเวียนเยอะมาก 3. ❌ **Edge Cases:** อาจเกิดประเด็นเรื่อง File lock หรือ missing temp files - 👉 *Mitigation:* ทำ Proper error handling พร้อม Logging ให้ตรวจสอบได้ง่าย + 👉 _Mitigation:_ ทำ Proper error handling พร้อม Logging ให้ตรวจสอบได้ง่าย --- ## 5. Performance Optimization Consideration + - **Streaming:** ใช้ multipart/form-data streaming เพิ่อลดภาระ Memory ของฝั่งเครื่องเซิฟเวอร์ (NestJS) ขณะสูบไฟล์ใหญ่ๆ - **Compression:** พิจารณาเรื่องการบีบอัดสำหรับไฟล์ขนาดใหญ่หรือบางประเภท - **Deduplication Check:** สามารถใช้งาน Field `checksum` ดักการ Commit ด้วยข้อมูลชุดเดิมที่เคยถูกอัปโหลดเพื่อประหยัดพื้นที่จัดเก็บ (Deduplicate) diff --git a/specs/03-Data-and-Storage/03-04-legacy-data-migration.md b/specs/03-Data-and-Storage/03-04-legacy-data-migration.md index cc1fca6..7b37066 100644 --- a/specs/03-Data-and-Storage/03-04-legacy-data-migration.md +++ b/specs/03-Data-and-Storage/03-04-legacy-data-migration.md @@ -35,25 +35,28 @@ ### Phase 1: การเตรียม Infrastructure และ Storage (สัปดาห์ที่ 1) **File Migration:** + - ย้ายไฟล์ PDF ทั้งหมดจากแหล่งเก็บไปยัง Folder ชั่วคราวบน NAS (QNAP) - Target Path: `/share/np-dms/staging_ai/` **Mount Folder:** + - Bind Mount `/share/np-dms/staging_ai/` เข้ากับ n8n Container แบบ **read-only** - สร้าง `/share/np-dms/n8n/migration_logs/` Volume แยกสำหรับเขียน Log แบบ **read-write** **Ollama Config:** + - ติดตั้ง Ollama บน Desktop (Desk-5439, RTX 2060 SUPER 8GB) - No DB credentials, Internal network only #### 🔍 เปรียบเทียบผลลัพธ์ที่คาดหวัง -| งาน | Typhoon2-4B | Qwen2.5-7B | OpenThaiGPT-7B | -|-----|-------------|------------|----------------| -| ความเร็ว (ток/วินาที) | ~35-45 | ~8-12 | ~10-15 | -| ความเข้าใจบริบทไทย | ดีมาก | ดี | ดีมาก | -| การสร้างแท็กแม่นยำ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | -| ความเสถียรบน 8GB | ✅ สูง | ⚠️ ปานกลาง | ⚠️ ปานกลาง | +| งาน | Typhoon2-4B | Qwen2.5-7B | OpenThaiGPT-7B | +| --------------------- | ----------- | ---------- | -------------- | +| ความเร็ว (ток/วินาที) | ~35-45 | ~8-12 | ~10-15 | +| ความเข้าใจบริบทไทย | ดีมาก | ดี | ดีมาก | +| การสร้างแท็กแม่นยำ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| ความเสถียรบน 8GB | ✅ สูง | ⚠️ ปานกลาง | ⚠️ ปานกลาง | ```bash # แนะนำ: llama3.2:3b (เร็ว, VRAM ~3GB, เหมาะ Classification) หรือ ollama run llama3.2:3b @@ -79,15 +82,18 @@ watch -n 1 nvidia-smi # Fallback: mistral:7b-instruct-q4_K_M (แม่นกว่า, VRAM ~5GB) # ollama pull mistral:7b-instruct-q4_K_M ``` + ใช้ ทางเลือกที่ 1 **ทดสอบ Ollama:** + ```bash curl http://192.168.20.100:11434/api/generate \ -d '{"model":"llama3.2:3b","prompt":"reply: ok","stream":false}' ``` **Concurrency Configuration:** + - Sequential: Batch Size = 1, Delay ≥ 2 วินาที, ปิด Parallel Execution - เพิ่ม Health Check Node ก่อนเริ่ม Batch เพื่อป้องกัน Workflow ค้างหาก Desktop Sleep หรือ Overheat @@ -96,6 +102,7 @@ curl http://192.168.20.100:11434/api/generate \ ### Phase 2: การเตรียม Target Database และ API (สัปดาห์ที่ 1) **SQL Indexing:** + ```sql ALTER TABLE correspondences ADD INDEX idx_doc_number (document_number); ALTER TABLE correspondences ADD INDEX idx_deleted_at (deleted_at); @@ -103,6 +110,7 @@ ALTER TABLE correspondences ADD INDEX idx_created_by (created_by); ``` **Checkpoint Table:** + ```sql CREATE TABLE IF NOT EXISTS migration_progress ( batch_id VARCHAR(50) PRIMARY KEY, @@ -113,6 +121,7 @@ CREATE TABLE IF NOT EXISTS migration_progress ( ``` **Tags Table (สำหรับ AI Tag Extraction):** + ```sql -- ตาราง Master เก็บ Tags (Global หรือ Project-specific) CREATE TABLE tags ( @@ -143,6 +152,7 @@ CREATE TABLE correspondence_tags ( ``` **Idempotency Table :** + ```sql CREATE TABLE IF NOT EXISTS import_transactions ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -158,6 +168,7 @@ CREATE TABLE IF NOT EXISTS import_transactions ( > **Idempotency Logic:** ถ้า `idempotency_key` ซ้ำ → Backend คืน HTTP 200 ทันที (ไม่สร้าง Revision ซ้ำ) ถ้าไม่ซ้ำ → ประมวลผลปกติ **API Authentication — Migration Token:** + ```sql INSERT INTO users (username, email, role, is_active) VALUES ('migration_bot', 'migration@system.internal', 'SYSTEM_ADMIN', true); @@ -165,14 +176,14 @@ VALUES ('migration_bot', 'migration@system.internal', 'SYSTEM_ADMIN', true); **Scope ของ Migration Token (Patch — คำนิยามชัดเจน):** -| สิทธิ์ | ปกติ | Migration Token | หมายเหตุ | -| ------------------------------------- | --- | --------------- | --------------------------------- | -| Bypass File Virus Scan | ❌ | ✅ | ไฟล์ผ่าน Scan มาแล้วก่อน Import | -| Bypass Duplicate **Validation Error** | ❌ | ✅ | **Revision Logic ยัง enforce ปกติ** | -| Bypass Created-by User validation | ❌ | ✅ | | -| Overwrite existing revision | ❌ | ❌ | **ห้ามโดยเด็ดขาด** | -| Delete previous revision | ❌ | ❌ | **ห้ามโดยเด็ดขาด** | -| ลบ / แก้ไข Record อื่น | ❌ | ❌ | **ห้ามโดยเด็ดขาด** | +| สิทธิ์ | ปกติ | Migration Token | หมายเหตุ | +| ------------------------------------- | ---- | --------------- | ----------------------------------- | +| Bypass File Virus Scan | ❌ | ✅ | ไฟล์ผ่าน Scan มาแล้วก่อน Import | +| Bypass Duplicate **Validation Error** | ❌ | ✅ | **Revision Logic ยัง enforce ปกติ** | +| Bypass Created-by User validation | ❌ | ✅ | | +| Overwrite existing revision | ❌ | ❌ | **ห้ามโดยเด็ดขาด** | +| Delete previous revision | ❌ | ❌ | **ห้ามโดยเด็ดขาด** | +| ลบ / แก้ไข Record อื่น | ❌ | ❌ | **ห้ามโดยเด็ดขาด** | > ⚠️ **Patch Clarification:** "Bypass Duplicate Number Check" ถูกแทนด้วย "Bypass Duplicate **Validation Error**" — Revision increment logic ยังทำงานตามปกติทุกกรณี @@ -194,21 +205,25 @@ VALUES ('migration_bot', 'migration@system.internal', 'SYSTEM_ADMIN', true); 4. File Mount Check → `staging_ai` มีไฟล์, `migration_logs` เขียนได้ **Fetch System Categories (Patch — ห้าม hardcode):** + ```http GET /api/meta/categories Authorization: Bearer ``` + Response: + ```json -{ "categories": ["Correspondence","RFA","Drawing","Transmittal","Report","Other"] } +{ "categories": ["Correspondence", "RFA", "Drawing", "Transmittal", "Report", "Other"] } ``` + n8n ต้องเก็บ categories นี้ไว้ใน Workflow Variable (`system_categories`) และ inject เข้า AI Prompt ทุก Request #### Node 1: Data Reader & Checkpoint #### Node 1: Data Reader & Checkpoint -- อ่าน Checkpoint จาก **MariaDB Node แยก** +- อ่าน Checkpoint จาก **MariaDB Node แยก** - Batch ทีละ **50–100 แถว** ตาม `$env.MIGRATION_BATCH_SIZE` (ควรจำกัด Batch Size ป้องกัน DB Connection Overload) - ติด `original_index` ทุก Item และ Normalize Encoding (UTF-8 NFC) สำหรับ ชื่อไฟล์ และ เลขเอกสารเก่า @@ -221,7 +236,7 @@ n8n ต้องเก็บ categories นี้ไว้ใน Workflow Variab 3. แปลง `receiver_code` -> `receiver_organization_id` 4. หา Tags ที่มีอยู่ในโปรเจ็กต์: `SELECT * FROM tags WHERE project_id = {{project_id}}` - **Output:** n8n เก็บ `project_id`, `organization_ids` และ `existing_tags_json` ไว้ในแต่ละ item -- *ถ้าหารหัสโปรเจ็กต์ไม่เจอ ให้ส่งเข้า Error Log ไม่ทำต่อ* +- _ถ้าหารหัสโปรเจ็กต์ไม่เจอ ให้ส่งเข้า Error Log ไม่ทำต่อ_ #### Node 3: File Processor (Extract PDF Text & Temp Upload) @@ -235,6 +250,7 @@ n8n ต้องเก็บ categories นี้ไว้ใน Workflow Variab #### Node 4: AI Analysis (Sequential เท่านั้น) **System Prompt:** + ```text You are a Document Controller for a large construction project. Your task is to validate document metadata, summarize content, and suggest relevant tags. @@ -242,6 +258,7 @@ You MUST respond ONLY with valid JSON. No explanation, no markdown. ``` **User Prompt:** + ```text Validate and summarize this document. Respond in JSON. Document Number: {{$json.document_number}} @@ -273,12 +290,14 @@ Respond ONLY with this exact JSON structure: ข้อมูลทั้งหมดที่ผ่าน n8n และ AI Model **จะต้องไม่ถูกอัพเดทเข้าตารางหลักอัตโนมัติ** แต่จะถูกบังคับนำเข้าตาราง Staging `migration_review_queue` แทน เพื่อรอมนุษย์จัดการผ่าน Frontend UI **Status Routing Policy:** + - `confidence >= 0.85` และ `is_valid = true` -> Status **`PENDING`** (พร้อมรับ Batch Import) - `confidence >= 0.60` และ `< 0.85` -> Status **`PENDING`** (ติด Flag ให้ระวัง) - `confidence < 0.60` หรือ `is_valid = false` -> Status **`REJECTED`** - Parse Error / AI ไม่ตอบ -> **Error Log** (Node ถัดไป) **Insert into staging:** + ```sql INSERT INTO migration_review_queue ( document_number, title, project_id, sender_organization_id, receiver_organization_id, @@ -290,7 +309,7 @@ ON DUPLICATE KEY UPDATE status = VALUES(status), ai_summary = VALUES(ai_summary) #### Node 6: Error Log & Reject Log -- Parse Error → เขียนลงไฟล์ `/share/np-dms/n8n/migration_logs/error_log.csv` +- Parse Error → เขียนลงไฟล์ `/share/np-dms/n8n/migration_logs/error_log.csv` - ทุก 10-50 ราบการอัพเดท MariaDB `migration_progress` เพื่อเป็น Checkpoint. --- @@ -308,10 +327,12 @@ ON DUPLICATE KEY UPDATE status = VALUES(status), ai_summary = VALUES(ai_summary) ### Phase 4: แผนการทดสอบ (Testing & QA) **Dry Run Policy (Mandatory):** + - All migrations MUST run with `--dry-run` - No DB commit until validation approved **Dry Run Validation (20–50 แถว):** + - JSON Parse Success Rate > 95% - Category ที่ AI ตอบตรงกับ System Enum ทุกรายการ - รัน Batch เดิมซ้ำ 2 รอบ → ต้องไม่สร้าง Duplicate หรือ Revision ซ้ำ (Idempotency Test) @@ -319,6 +340,7 @@ ON DUPLICATE KEY UPDATE status = VALUES(status), ai_summary = VALUES(ai_summary) - Revision Drift ถูก route ไป Review Queue **Integrity Check:** + ```sql -- ตรวจยอด SELECT COUNT(*) FROM correspondences WHERE created_by = 'SYSTEM_IMPORT'; @@ -351,11 +373,13 @@ WHERE created_by = 'SYSTEM_IMPORT' AND action = 'IMPORT'; ## 4. Rollback Plan **Step 1:** หยุด n8n และ Disable Token + ```sql UPDATE users SET is_active = false WHERE username = 'migration_bot'; ``` **Step 2:** ลบ Records (Transaction) + ```sql START TRANSACTION; DELETE FROM correspondence_files @@ -369,6 +393,7 @@ COMMIT; **Step 3:** ย้ายไฟล์กลับ `/share/np-dms/staging_ai/` ผ่าน Script แยก **Step 4:** Reset State + ```sql UPDATE migration_progress SET status = 'FAILED', last_processed_index = 0 @@ -383,19 +408,19 @@ WHERE batch_id = 'migration_20260226'; ## 5. แผนรับมือความเสี่ยง (Risk Management) -| ลำดับที่ | ความเสี่ยง | การจัดการ (Mitigation) | -| ---- | -------------------------- | -------------------------------------------------- | -| 1 | AI Node หรือ GPU ค้าง | Timeout 30 วินาที, Retry 3 รอบ, Delay 60 วินาที | -| 2 | Ollama ตอบไม่ใช่ JSON | JSON Pre-processor + ส่ง Human Review Queue | -| 3 | Category ไม่ตรง System Enum | Fetch `/api/meta/categories` ก่อน Batch ทุกครั้ง | -| 4 | Idempotency ซ้ำ | `import_transactions` table + Backend คืน HTTP 200 | -| 5 | Revision Drift | ตรวจ Excel revision column → Route ไป Review Queue | -| 6 | Storage bypass | ห้าม move file โดยตรง — ผ่าน Backend API เท่านั้น | -| 7 | GPU VRAM Overflow | ใช้เฉพาะ Quantized Model (q4_K_M) | -| 8 | ดิสก์ NAS เต็ม | ปิด "Save Successful Executions" ใน n8n | -| 9 | Migration Token ถูกขโมย | Token 7 วัน, IP Whitelist `` เท่านั้น | -| 11 | AI Tag Extraction ผิดพลาด | Tag confidence < 0.6 → ส่งไป Review Queue / บันทึกใน metadata | -| 12 | Tag ซ้ำ/คล้ายกัน | Normalization ก่อนบันทึก (lowercase, trim, deduplicate) | +| ลำดับที่ | ความเสี่ยง | การจัดการ (Mitigation) | +| -------- | --------------------------- | ------------------------------------------------------------- | +| 1 | AI Node หรือ GPU ค้าง | Timeout 30 วินาที, Retry 3 รอบ, Delay 60 วินาที | +| 2 | Ollama ตอบไม่ใช่ JSON | JSON Pre-processor + ส่ง Human Review Queue | +| 3 | Category ไม่ตรง System Enum | Fetch `/api/meta/categories` ก่อน Batch ทุกครั้ง | +| 4 | Idempotency ซ้ำ | `import_transactions` table + Backend คืน HTTP 200 | +| 5 | Revision Drift | ตรวจ Excel revision column → Route ไป Review Queue | +| 6 | Storage bypass | ห้าม move file โดยตรง — ผ่าน Backend API เท่านั้น | +| 7 | GPU VRAM Overflow | ใช้เฉพาะ Quantized Model (q4_K_M) | +| 8 | ดิสก์ NAS เต็ม | ปิด "Save Successful Executions" ใน n8n | +| 9 | Migration Token ถูกขโมย | Token 7 วัน, IP Whitelist `` เท่านั้น | +| 11 | AI Tag Extraction ผิดพลาด | Tag confidence < 0.6 → ส่งไป Review Queue / บันทึกใน metadata | +| 12 | Tag ซ้ำ/คล้ายกัน | Normalization ก่อนบันทึก (lowercase, trim, deduplicate) | --- diff --git a/specs/03-Data-and-Storage/03-05-n8n-migration-setup-guide.md b/specs/03-Data-and-Storage/03-05-n8n-migration-setup-guide.md index bfd6fa0..34550bc 100644 --- a/specs/03-Data-and-Storage/03-05-n8n-migration-setup-guide.md +++ b/specs/03-Data-and-Storage/03-05-n8n-migration-setup-guide.md @@ -9,13 +9,13 @@ ## ⚠️ ความแตกต่างจากเวอร์ชัน Enterprise -| ฟีเจอร์ | Enterprise | Free Plan (นี้) | -| --------------------- | ----------------------- | ------------------------------ | -| Environment Variables | ✅ ใช้ `$env` | ❌ ใช้ `Set Node` + `staticData` | -| External Secrets | ✅ Vault/Secrets Manager | ❌ Hardcode ใน Set Node | -| Multiple Workflows | ✅ Unlimited | ⚠️ รวมเป็น Workflow เดียว | -| Error Handling | ✅ Advanced | ⚠️ Manual Retry | -| Webhook Triggers | ✅ | ✅ ใช้ได้ | +| ฟีเจอร์ | Enterprise | Free Plan (นี้) | +| --------------------- | ------------------------ | -------------------------------- | +| Environment Variables | ✅ ใช้ `$env` | ❌ ใช้ `Set Node` + `staticData` | +| External Secrets | ✅ Vault/Secrets Manager | ❌ Hardcode ใน Set Node | +| Multiple Workflows | ✅ Unlimited | ⚠️ รวมเป็น Workflow เดียว | +| Error Handling | ✅ Advanced | ⚠️ Manual Retry | +| Webhook Triggers | ✅ | ✅ ใช้ได้ | --- @@ -75,7 +75,7 @@ **สิ่งสำคัญ:** -| Item | ค่า Production | +| Item | ค่า Production | | ------------ | ----------------------------------------------------------------------------------------------- | | Image | `n8nio/n8n:latest` | | Container | `n8n` | @@ -134,7 +134,7 @@ const CONFIG = { // Thresholds CONFIDENCE_HIGH: 0.85, - CONFIDENCE_LOW: 0.60, + CONFIDENCE_LOW: 0.6, MAX_RETRY: 3, FALLBACK_THRESHOLD: 5, @@ -147,14 +147,14 @@ const CONFIG = { DB_PORT: 3306, DB_NAME: 'lcbp3_production', DB_USER: 'migration_bot', - DB_PASSWORD: 'YOUR_DB_PASSWORD_HERE' // 🔴 เปลี่ยน + DB_PASSWORD: 'YOUR_DB_PASSWORD_HERE', // 🔴 เปลี่ยน }; // อย่าแก้โค้ดด้านล่างนี้ $workflow.staticData = $workflow.staticData || {}; $workflow.staticData.config = CONFIG; -return [{ json: { config_loaded: true, timestamp: new Date().toISOString() }}]; +return [{ json: { config_loaded: true, timestamp: new Date().toISOString() } }]; ``` ### ขั้นตอนที่ 2: ตั้งค่า Credentials ใน n8n UI @@ -166,7 +166,6 @@ return [{ json: { config_loaded: true, timestamp: new Date().toISOString() }}]; 3. **Rotate Token ทันทีหลัง Migration เสร็จ** 4. **💡 หมายเหตุ:** Backend ระบบ DMS ได้ถูกตั้งค่าให้สร้าง Token แบบไม่มีวันหมดอายุ (100 ปี) สำหรับ User ชื่อ `migration_bot` โดยเฉพาะ เพื่อป้องกันปัญหา Token หมดอายุระหว่างที่ Workflow กำลังทำงานข้ามวัน - **Credentials (ถ้าใช้):** | Credential | Type | ใช้ใน Node | @@ -175,7 +174,6 @@ return [{ json: { config_loaded: true, timestamp: new Date().toISOString() }}]; | LCBP3 Backend | HTTP Request | Import to Backend, Fetch Categories | | MariaDB | MySQL | ทุก Database Node | - ### ขั้นตอนที่ 3: วิธีการรับ MIGRATION_TOKEN เนื่องจากหน้าเว็บ DMS ใช้ระบบ Session Cookies (Auth.js) จึงไม่สามารถคัดลอก JWT Token จาก Network Tab ในเบราว์เซอร์ได้โดยตรง @@ -183,6 +181,7 @@ return [{ json: { config_loaded: true, timestamp: new Date().toISOString() }}]; ให้ใช้วิธี **เรียก API ตรงไปที่ Backend** ด้วยเครื่องมืออย่าง Postman, cURL หรือ Thunder Client แทน: **ตัวอย่างคำสั่ง cURL:** + ```bash curl -X POST https://api.np-dms.work/api/auth/login \ -H "Content-Type: application/json" \ @@ -190,6 +189,7 @@ curl -X POST https://api.np-dms.work/api/auth/login \ ``` **การนำไปใช้งาน:** + 1. เปลี่ยน URL ให้ตรงกับ Backend ของคุณ (เช่น `http://localhost:3001/api/auth/login` สำหรับ Local) 2. นำรหัสผ่านของบัญชี `migration_bot` มาใส่แทนที่ `YOUR_PASSWORD` 3. ในผลลัพธ์ที่ได้ ให้คัดลอกเฉพาะค่าจากฟิลด์ `access_token` (ข้อความยาวๆ) @@ -211,59 +211,67 @@ mysql -h -u migration_bot -p lcbp3_production < lcbp3-v1.8.0-migration **ตารางที่สร้าง (6 ตาราง ชั่วคราว — ลบได้หลัง Migration เสร็จ):** | ตาราง | วัตถุประสงค์ | -| -------------------------- | ------------------------------- | +| -------------------------- | ---------------------------------- | | `migration_progress` | Checkpoint ติดตามความคืบหน้า Batch | | `migration_review_queue` | รายการที่ต้องตรวจสอบโดยคน | -| `migration_errors` | Error Log | -| `migration_fallback_state` | สถานะ AI Model Fallback | -| `import_transactions` | Idempotency ป้องกัน Import ซ้ำ | -| `migration_daily_summary` | สรุปผลรายวัน | +| `migration_errors` | Error Log | +| `migration_fallback_state` | สถานะ AI Model Fallback | +| `import_transactions` | Idempotency ป้องกัน Import ซ้ำ | +| `migration_daily_summary` | สรุปผลรายวัน | --- ## 📌 ส่วนที่ 4: การทำงานของแต่ละ Node ### Node 0: Set Configuration + - เก็บค่า Config ทั้งหมดใน `$workflow.staticData.config` - อ่านผ่าน `$workflow.staticData.config.KEY` ใน Node อื่น ### Node 1: Pre-flight Checks & Data Reader + - ตรวจสอบ Backend Health และ Ollama Ping - อ่าน Checkpoint (`last_processed_index`) จาก `migration_progress` - Batch ข้อมูลจาก Excel ตามตาราง `BATCH_SIZE` ปกติ (50-100) - Normalize ข้อมูล UTF-8 (NFC) และสร้าง `original_index` ### Node 2: DB Lookup & Categories Fetch + - ดึง Categories จาก `/api/meta/categories` เพื่อเตรียม Prompt - Query ทะลวง DB: แปลงรหัสใน Excel (`project_code`, `sender`, `receiver`) ให้เป็น IDs จาก MariaDB - Query ดึง Master Tags ของโปรเจ็กต์: `SELECT tag_name, description FROM tags WHERE project_id = ...` - Output: แปลง ID เรียบร้อยและเตรียม `existing_tags_json` ให้ Ollama ### Node 3: Text Extraction & Temp Upload + - ใช้ **Apache Tika** (ผ่าน `Extract PDF Text` node หรือ HTTP Request) สกัดข้อความ (OCR/Text) ออกจาก PDF ใน staging - แนบไฟล์ไปยัง Backend: ยิง HTTP Request **`POST /api/storage/upload`** ของ Backend - รอรับผลลัพธ์เป็น `temp_attachment_id` (หมายความว่าไฟล์นี้เข้าข่าย Temporary ถูกเก็บจัดการใน NAS เรียบร้อยแล้ว) - Output: ไฟล์พร้อมใช้งาน, ได้เนื้อหา Text มาเตรียม prompt ### Node 4: AI Analysis + - วาง System Prompt บังคับ Output JSON - โยน Metadata (Title, Date, DB Lookups) พร้อม Extracted PDF Text คุยกับ **Ollama `llama3.2:3b`** -- ให้ AI วิเคราะห์ และสรุปเป็น `ai_summary` +- ให้ AI วิเคราะห์ และสรุปเป็น `ai_summary` - ให้ AI แนะนำ Tags ใหม่หรือเลือก Tags เดิมจาก `existing_tags_json` ### Node 5: Parse & Validate + - Schema Validation (ดูให้แน่ใจว่า AI ตอบ `is_valid`, `confidence`, `summary`, `suggested_tags`) - Normalizing categories, trimming tags (`is_new: true / false` flag สำคัญมาก) - จัดชุดค่า Status ใหม่ ### Node 6: Confidence Router & Staging Ingest + **แยกสาย 4 สาย:** -1. **PENDING (Auto Ready):** (`confidence ≥ 0.85` && `is_valid = true`) → INSERT เข้า `migration_review_queue` + +1. **PENDING (Auto Ready):** (`confidence ≥ 0.85` && `is_valid = true`) → INSERT เข้า `migration_review_queue` 2. **PENDING (Flagged):** (`confidence 0.60 - 0.84`) → INSERT เข้า `migration_review_queue` พร้อม Highlight/Remarks ให้ Admin ดูละเอียด 3. **REJECTED:** (`confidence < 0.60` หรือ `is_valid = false`) → INSERT เข้า `migration_review_queue` สถานะรอแก้แบบ Manual 4. **Error/Parse Fail:** ไปลง CSV Reject Log + DB `migration_errors` -**สำคัญมาก:** *n8n จะทำหน้าที่สูบข้อมูลและจัดเตรียมเข้า `migration_review_queue` เท่านั้น จะไม่มีการข้ามขั้นตอนไป Import ลงตารางหลัก `correspondences` อัตโนมัติ (Final Commit ต้องทำบน Frontend UI)* +**สำคัญมาก:** _n8n จะทำหน้าที่สูบข้อมูลและจัดเตรียมเข้า `migration_review_queue` เท่านั้น จะไม่มีการข้ามขั้นตอนไป Import ลงตารางหลัก `correspondences` อัตโนมัติ (Final Commit ต้องทำบน Frontend UI)_ --- @@ -306,6 +314,7 @@ DELETE FROM migration_fallback_state WHERE batch_id = ''; ``` **Confirmation Guard:** + ```javascript if ($input.first().json.confirmation !== 'CONFIRM_ROLLBACK') { throw new Error('Rollback cancelled: type "CONFIRM_ROLLBACK" to proceed.'); @@ -317,13 +326,13 @@ return $input.all(); ## 📌 ส่วนที่ 6: Daily Operation -| เวลา | กิจกรรม | ผู้รับผิดชอบ | +| เวลา | กิจกรรม | ผู้รับผิดชอบ | | ----- | ------------------------------ | ------------------- | | 08:00 | ตรวจสอบ Night Summary Email | Admin | | 09:00 | Approve/Reject ใน Review Queue | Document Controller | | 17:00 | ตรวจ Disk Space + GPU Temp | DevOps | -| 22:00 | Workflow เริ่มรันอัตโนมัติ | System | -| 06:30 | Night Summary Report ส่ง Email | System | +| 22:00 | Workflow เริ่มรันอัตโนมัติ | System | +| 06:30 | Night Summary Report ส่ง Email | System | ### Emergency Stop @@ -349,16 +358,19 @@ mysql -h -u root -p \ ## 🚨 ข้อควรระวังสำหรับ Free Plan ### 1. Security + - **อย่า Commit ไฟล์นี้เข้า Git** ถ้ามี Password/Token - ใช้ `.gitignore` สำหรับไฟล์ JSON ที่มี Config - Rotate Token ทันทีหลังใช้งาน ### 2. Limitations + - **Execution Timeout**: ตรวจสอบ n8n execution timeout (default 5 นาที) - **Memory**: จำกัดที่ 2GB (ตาม Docker Compose) - **Concurrent**: รัน Batch ต่อเนื่อง ไม่ parallel ### 3. Backup + - สำรอง PostgreSQL data ที่ `/share/np-dms/n8n/postgres-data` - สำรอง n8n data ที่ `/share/np-dms/n8n` - สำรอง Logs ที่ `/share/np-dms/n8n/migration_logs` @@ -367,67 +379,82 @@ mysql -h -u root -p \ ## ✅ Pre-Production Checklist (Free Plan) -| ลำดับ | รายการ | วิธีตรวจสอบ | -| --- | ---------------------- | ----------------------------------------------------------------- | -| 1 | Config ถูกต้อง | รัน Test Execution ดูผลลัพธ์ Node 0 | -| 2 | Database Connect ได้ | Test Step ใน Node Read Checkpoint | -| 3 | Ollama พร้อม | `curl http:///api/tags` | -| 4 | Backend Token ใช้ได้ | Test Step ใน Node Fetch Categories | -| 5 | File Mount RO ถูกต้อง | `docker exec n8n ls /home/node/.n8n-files/staging_ai` | -| 6 | Log Mount RW ถูกต้อง | `docker exec n8n touch /home/node/.n8n-files/migration_logs/test` | -| 7 | Categories ไม่ hardcode | ดูผลลัพธ์ Node Fetch Categories | -| 8 | Tags โหลดถูกต้อง | ดูผลลัพธ์ Node Fetch Tags (ควรแสดงรายการ Tags ที่มีอยู่) | -| 9 | AI Tag Extraction ทำงาน | ตรวจ `suggested_tags` ใน Response จาก Parse & Validate Node | -| 10 | Idempotency Key ถูกต้อง | ตรวจ Header ใน Node Import | -| 11 | Checkpoint บันทึก | ตรวจสอบ `migration_progress` หลังรัน | -| 12 | Error Log สร้างไฟล์ | ตรวจสอบ `error_log.csv` | +| ลำดับ | รายการ | วิธีตรวจสอบ | +| ----- | ----------------------- | ----------------------------------------------------------------- | +| 1 | Config ถูกต้อง | รัน Test Execution ดูผลลัพธ์ Node 0 | +| 2 | Database Connect ได้ | Test Step ใน Node Read Checkpoint | +| 3 | Ollama พร้อม | `curl http:///api/tags` | +| 4 | Backend Token ใช้ได้ | Test Step ใน Node Fetch Categories | +| 5 | File Mount RO ถูกต้อง | `docker exec n8n ls /home/node/.n8n-files/staging_ai` | +| 6 | Log Mount RW ถูกต้อง | `docker exec n8n touch /home/node/.n8n-files/migration_logs/test` | +| 7 | Categories ไม่ hardcode | ดูผลลัพธ์ Node Fetch Categories | +| 8 | Tags โหลดถูกต้อง | ดูผลลัพธ์ Node Fetch Tags (ควรแสดงรายการ Tags ที่มีอยู่) | +| 9 | AI Tag Extraction ทำงาน | ตรวจ `suggested_tags` ใน Response จาก Parse & Validate Node | +| 10 | Idempotency Key ถูกต้อง | ตรวจ Header ใน Node Import | +| 11 | Checkpoint บันทึก | ตรวจสอบ `migration_progress` หลังรัน | +| 12 | Error Log สร้างไฟล์ | ตรวจสอบ `error_log.csv` | --- ## 🔧 การแก้ไขปัญหาเฉพาะหน้า ### ปัญหา: Config ไม่ถูกต้อง + **แก้ไข:** แก้ที่ Node "Set Configuration" แล้ว Save → Execute Workflow ใหม่ ### ปัญหา: Database Connection Error + **ตรวจสอบ:** + ```javascript // ใส่ใน Code Node ชั่วคราวเพื่อ Debug const config = $workflow.staticData.config; -return [{ json: { - host: config.DB_HOST, - port: config.DB_PORT, - // อย่าแสดง password ใน Production! - test: 'Config loaded: ' + (config ? 'YES' : 'NO') -}}]; +return [ + { + json: { + host: config.DB_HOST, + port: config.DB_PORT, + // อย่าแสดง password ใน Production! + test: 'Config loaded: ' + (config ? 'YES' : 'NO'), + }, + }, +]; ``` ### ปัญหา: AI Tag Extraction ไม่ทำงาน + **ตรวจสอบ:** + 1. ดู Response ใน Node "Parse & Validate" ว่ามี field `suggested_tags` หรือไม่ 2. ถ้าไม่มี → ตรวจสอบ Prompt ใน "Build AI Prompt" ว่ารวม Tag Extraction Instructions แล้ว 3. ถ้า AI ตอบแต่ Tags ไม่ถูกต้อง → ปรับ Threshold หรือส่งไป Review Queue ```javascript // Debug Code Node ชั่วคราว -return [{ - json: { - has_suggested_tags: !!$json.ai_result?.suggested_tags, - tag_count: $json.ai_result?.suggested_tags?.length || 0, - suggested_tags: $json.ai_result?.suggested_tags, - tag_confidence: $json.ai_result?.tag_confidence - } -}]; +return [ + { + json: { + has_suggested_tags: !!$json.ai_result?.suggested_tags, + tag_count: $json.ai_result?.suggested_tags?.length || 0, + suggested_tags: $json.ai_result?.suggested_tags, + tag_confidence: $json.ai_result?.tag_confidence, + }, + }, +]; ``` ### ปัญหา: Tags ซ้ำหรือผิดพลาด + **แก้ไข:** + - ใช้ SQL ตรวจสอบ Tags ที่ซ้ำ: + ```sql SELECT tag_name, COUNT(*) as cnt FROM tags WHERE created_by = (SELECT user_id FROM users WHERE username = 'migration_bot') GROUP BY tag_name HAVING cnt > 1; ``` + - ถ้าพบซ้ำ → ใช้ Node Normalize ก่อนบันทึก (มีแล้วใน Parse & Validate) --- @@ -468,7 +495,7 @@ mysql -h -u migration_bot -p -e "SELECT COUNT(DISTINCT ct.corresponden ## 📞 การติดต่อสนับสนุน -| ปัญหา | ช่องทางติดต่อ | +| ปัญหา | ช่องทางติดต่อ | | --------------- | ------------------------------------------- | | Technical Issue | DevOps Team (Slack: #migration-support) | | Data Issue | Document Controller (Email: dc@lcbp3.local) | diff --git a/specs/03-Data-and-Storage/03-06-migration-business-scope.md b/specs/03-Data-and-Storage/03-06-migration-business-scope.md index 3c77198..b01d57d 100644 --- a/specs/03-Data-and-Storage/03-06-migration-business-scope.md +++ b/specs/03-Data-and-Storage/03-06-migration-business-scope.md @@ -1,17 +1,20 @@ # 📦 Legacy Data Migration — Business Scope & Governance --- + title: 'Migration Business Scope, Data Governance, and Go/No-Go Gates' version: 1.0.0 status: DRAFT — Awaiting Stakeholder Confirmation owner: Nattanin Peancharoen (PO + Migration Lead) last_updated: 2026-03-11 related: - - specs/03-Data-and-Storage/03-04-legacy-data-migration.md ← Technical Implementation - - specs/03-Data-and-Storage/03-05-n8n-migration-setup-guide.md - - specs/06-Decision-Records/ADR-017-ollama-data-migration.md - - specs/06-Decision-Records/ADR-018-ai-boundary.md - - specs/00-Overview/00-04-stakeholder-signoff-and-risk.md ← Risk Register (RISK-002) + +- specs/03-Data-and-Storage/03-04-legacy-data-migration.md ← Technical Implementation +- specs/03-Data-and-Storage/03-05-n8n-migration-setup-guide.md +- specs/06-Decision-Records/ADR-017-ollama-data-migration.md +- specs/06-Decision-Records/ADR-018-ai-boundary.md +- specs/00-Overview/00-04-stakeholder-signoff-and-risk.md ← Risk Register (RISK-002) + --- > [!IMPORTANT] @@ -26,12 +29,12 @@ related: ## 1. 🎯 Migration Objective -| วัตถุประสงค์ | รายละเอียด | -|------------|-----------| -| **Continuity** | ผู้ใช้สามารถค้นหาและอ้างอิงเอกสารเก่าในระบบใหม่ได้ทันที | -| **Traceability** | Workflow ใหม่สามารถ Link กลับไปยัง Correspondence เก่าได้ | +| วัตถุประสงค์ | รายละเอียด | +| ----------------- | ------------------------------------------------------------- | +| **Continuity** | ผู้ใช้สามารถค้นหาและอ้างอิงเอกสารเก่าในระบบใหม่ได้ทันที | +| **Traceability** | Workflow ใหม่สามารถ Link กลับไปยัง Correspondence เก่าได้ | | **Searchability** | เอกสารเก่าถูก Index ใน Elasticsearch — ค้นหาได้ด้วย Full-text | -| **Compliance** | Audit Trail ครบ: รู้ว่าใครนำเข้า เมื่อไหร่ จาก Batch ไหน | +| **Compliance** | Audit Trail ครบ: รู้ว่าใครนำเข้า เมื่อไหร่ จาก Batch ไหน | --- @@ -39,19 +42,21 @@ related: ### 2.1 ✅ IN SCOPE — นำเข้าระบบใหม่ -| ประเภทเอกสาร | Subdirectory | Volume (ประมาณ) | Priority | -|-------------|-------------|----------------|---------| -| **Correspondence** (Letters, RFI) | `CORR/` | ~8,000 ไฟล์ | 🔴 High | -| **RFA + Shop Drawings** | `RFA/` | ~5,000 ไฟล์ | 🔴 High | -| **Contract Drawings** | `CD/` | ~3,000 ไฟล์ | 🟠 Medium | -| **Transmittals** | `TRM/` | ~2,000 ไฟล์ | 🟠 Medium | -| **Reports & Minutes** | `RPT/` | ~2,000 ไฟล์ | 🟡 Low | +| ประเภทเอกสาร | Subdirectory | Volume (ประมาณ) | Priority | +| --------------------------------- | ------------ | --------------- | --------- | +| **Correspondence** (Letters, RFI) | `CORR/` | ~8,000 ไฟล์ | 🔴 High | +| **RFA + Shop Drawings** | `RFA/` | ~5,000 ไฟล์ | 🔴 High | +| **Contract Drawings** | `CD/` | ~3,000 ไฟล์ | 🟠 Medium | +| **Transmittals** | `TRM/` | ~2,000 ไฟล์ | 🟠 Medium | +| **Reports & Minutes** | `RPT/` | ~2,000 ไฟล์ | 🟡 Low | **ช่วงเวลาที่ Include:** + - **เริ่มต้น:** 1 มกราคม 2564 (โครงการเริ่ม) - **สิ้นสุด:** วันก่อน Go-Live — 1 วัน (เอกสารหลังจากนั้นใช้ระบบใหม่) **เงื่อนไข Include:** + - ไฟล์ต้องเป็น PDF (หรือ DWG สำหรับ Drawing) - ไฟล์ต้อง Readable โดย Tika/Ollama (ไม่ Corrupted) - มี Row ใน Excel Metadata ที่ตรงกัน (document_number ไม่ว่าง) @@ -60,16 +65,16 @@ related: ### 2.2 ❌ OUT OF SCOPE — ไม่นำเข้า -| รายการ | เหตุผล | -|--------|-------| -| **เอกสารก่อนปี 2564** | ก่อนเริ่มโครงการ LCBP3 Phase 3 | -| **Email Body / Attachments ที่ไม่ใช่ PDF** | Format ไม่รองรับ | -| **Draft ที่ไม่เคย Submit** | ไม่มีเลขเอกสารทางการ | -| **ไฟล์ที่ Corrupted หรืออ่านไม่ได้** | ไปที่ Reject Log | -| **ข้อมูล Financial / Cost Records** | ไม่อยู่ใน DMS Scope | -| **Personal Communication (ไม่มีเลขทางการ)** | ไม่ใช่เอกสารทางการ | -| **วิดีโอ / รูปภาพ Standalone** | ไม่ใช่ Document | -| **ไฟล์ DWG ที่ไม่มี PDF คู่** | ออก PDF ก่อนนำเข้า (Admin Task) | +| รายการ | เหตุผล | +| ------------------------------------------- | ------------------------------- | +| **เอกสารก่อนปี 2564** | ก่อนเริ่มโครงการ LCBP3 Phase 3 | +| **Email Body / Attachments ที่ไม่ใช่ PDF** | Format ไม่รองรับ | +| **Draft ที่ไม่เคย Submit** | ไม่มีเลขเอกสารทางการ | +| **ไฟล์ที่ Corrupted หรืออ่านไม่ได้** | ไปที่ Reject Log | +| **ข้อมูล Financial / Cost Records** | ไม่อยู่ใน DMS Scope | +| **Personal Communication (ไม่มีเลขทางการ)** | ไม่ใช่เอกสารทางการ | +| **วิดีโอ / รูปภาพ Standalone** | ไม่ใช่ Document | +| **ไฟล์ DWG ที่ไม่มี PDF คู่** | ออก PDF ก่อนนำเข้า (Admin Task) | --- @@ -107,31 +112,31 @@ Tier 3 — นำเข้าภายใน 1 เดือนหลัง Go-Li ### 3.1 Excel Metadata Schema (Legacy) -| Column | Field ใหม่ | บังคับ | หมายเหตุ | -|--------|----------|-------|---------| -| `DOC_NO` | `document_number` | ✅ | ใช้เป็น Idempotency Key | -| `TITLE` | `title` | ✅ | AI จะ Suggest แก้ไขถ้าผิด Format | -| `DATE` | `reference_date` | ✅ | วันที่เอกสาร (ไม่ใช่วันนำเข้า) | -| `FROM_ORG` | `sender_org_id` | ✅ | Map ด้วย org_code lookup table | -| `TO_ORG` | `receiver_org_id` | ✅ | Map ด้วย org_code lookup table | -| `TYPE` | `category` | ✅ | AI ตรวจสอบ Enum ที่ถูกต้อง | -| `DISCIPLINE` | `discipline` | ❌ | Optional — AI Extract จาก Title | -| `CONTRACT_NO` | `contract_id` | ❌ | Map ด้วย contract lookup table | -| `PROJECT_NO` | `project_id` | ✅ | ต้องมี (ทุกเอกสาร) | -| `FILE_PATH` | `source_file_path` | ✅ | Path ใน NAS staging folder | -| `REVISION` | `revision` | ❌ | Detect จากเลขเอกสาร | +| Column | Field ใหม่ | บังคับ | หมายเหตุ | +| ------------- | ------------------ | ------ | -------------------------------- | +| `DOC_NO` | `document_number` | ✅ | ใช้เป็น Idempotency Key | +| `TITLE` | `title` | ✅ | AI จะ Suggest แก้ไขถ้าผิด Format | +| `DATE` | `reference_date` | ✅ | วันที่เอกสาร (ไม่ใช่วันนำเข้า) | +| `FROM_ORG` | `sender_org_id` | ✅ | Map ด้วย org_code lookup table | +| `TO_ORG` | `receiver_org_id` | ✅ | Map ด้วย org_code lookup table | +| `TYPE` | `category` | ✅ | AI ตรวจสอบ Enum ที่ถูกต้อง | +| `DISCIPLINE` | `discipline` | ❌ | Optional — AI Extract จาก Title | +| `CONTRACT_NO` | `contract_id` | ❌ | Map ด้วย contract lookup table | +| `PROJECT_NO` | `project_id` | ✅ | ต้องมี (ทุกเอกสาร) | +| `FILE_PATH` | `source_file_path` | ✅ | Path ใน NAS staging folder | +| `REVISION` | `revision` | ❌ | Detect จากเลขเอกสาร | ### 3.2 Organization Code Mapping > ต้องสร้าง Lookup Table ก่อนเริ่ม Migration — Superadmin ทำใน Pre-migration Setup -| Legacy Code (Excel) | Organization ใหม่ | org_id (System) | -|--------------------|-----------------|----------------| -| กทท. | การท่าเรือแห่งประเทศไทย | TBD (ดูจาก DB) | -| สค. | สำนักงานโครงการ | TBD | -| TEAM | TEAM | TBD | -| คคง. | คณะกรรมการตรวจงาน | TBD | -| ผรม. | ผู้รับจ้างหลัก | TBD | +| Legacy Code (Excel) | Organization ใหม่ | org_id (System) | +| ------------------- | ----------------------- | --------------- | +| กทท. | การท่าเรือแห่งประเทศไทย | TBD (ดูจาก DB) | +| สค. | สำนักงานโครงการ | TBD | +| TEAM | TEAM | TBD | +| คคง. | คณะกรรมการตรวจงาน | TBD | +| ผรม. | ผู้รับจ้างหลัก | TBD | > **Action Item:** Superadmin ต้อง Fill in `org_id` ก่อน Migration เริ่ม @@ -183,15 +188,15 @@ T+1 เดือน: ### Gate #1: Before Production Migration Starts (T-3 สัปดาห์) -| เกณฑ์ | ต้องผ่าน | วิธีวัด | -|-------|---------|--------| -| Dry Run 2 JSON Parse Success | ≥ 95% | n8n Execution Log | -| Dry Run 2 AI Category Accuracy | ≥ 90% (Manual Spot-check 50 docs) | Human Review | -| Idempotency Test: รัน Batch ซ้ำ | 0 Duplicate Records | SQL Count | -| Organization Mapping ครบ | 100% | Lookup Table review | -| Frontend Review UI พร้อมใช้งาน | ✅ | UAT Passed สำหรับหน้าจออนุมัติ | -| Migration Bot Token Active + Whitelisted | ✅ | API Test | -| Staging NAS Space: ≥ 500GB free | ✅ | QNAP Dashboard | +| เกณฑ์ | ต้องผ่าน | วิธีวัด | +| ---------------------------------------- | --------------------------------- | ------------------------------ | +| Dry Run 2 JSON Parse Success | ≥ 95% | n8n Execution Log | +| Dry Run 2 AI Category Accuracy | ≥ 90% (Manual Spot-check 50 docs) | Human Review | +| Idempotency Test: รัน Batch ซ้ำ | 0 Duplicate Records | SQL Count | +| Organization Mapping ครบ | 100% | Lookup Table review | +| Frontend Review UI พร้อมใช้งาน | ✅ | UAT Passed สำหรับหน้าจออนุมัติ | +| Migration Bot Token Active + Whitelisted | ✅ | API Test | +| Staging NAS Space: ≥ 500GB free | ✅ | QNAP Dashboard | **Owner:** Nattanin P. | **Approver:** Org Admin ทุกองค์กร @@ -199,40 +204,40 @@ T+1 เดือน: ### Gate #2: Before Go-Live (T-1 วัน) -| เกณฑ์ | ต้องผ่าน | -|-------|---------| -| Tier 1 Migration: 100% เสร็จ + Verified | ✅ | -| Tier 2 Migration: ≥ 90% เสร็จ + Verified | ✅ | -| Review Queue (รวมการพิจารณา AI New Tags): ≤ 5% ค้างอยู่ (Critical Tier 1 = 0%) | ✅ | -| Migration Bot Token: REVOKED | ✅ | -| Integrity Queries ผ่านทั้งหมด | ✅ | -| Legacy System ยังเข้าถึงได้ (Read-only Fallback) | ✅ | +| เกณฑ์ | ต้องผ่าน | +| ------------------------------------------------------------------------------ | -------- | +| Tier 1 Migration: 100% เสร็จ + Verified | ✅ | +| Tier 2 Migration: ≥ 90% เสร็จ + Verified | ✅ | +| Review Queue (รวมการพิจารณา AI New Tags): ≤ 5% ค้างอยู่ (Critical Tier 1 = 0%) | ✅ | +| Migration Bot Token: REVOKED | ✅ | +| Integrity Queries ผ่านทั้งหมด | ✅ | +| Legacy System ยังเข้าถึงได้ (Read-only Fallback) | ✅ | --- ### Gate #3: Post Go-Live (T+30 วัน) -| เกณฑ์ | ต้องผ่าน | -|-------|---------| -| Tier 3 Migration: 100% เสร็จ | ✅ | -| User Search Test: สามารถค้นหา Legacy Doc ใน ES | ✅ | -| Zero Orphan Files ใน Staging | ✅ | -| Legacy System Archive เสร็จ (Compress + Store) | ✅ | +| เกณฑ์ | ต้องผ่าน | +| ---------------------------------------------- | -------- | +| Tier 3 Migration: 100% เสร็จ | ✅ | +| User Search Test: สามารถค้นหา Legacy Doc ใน ES | ✅ | +| Zero Orphan Files ใน Staging | ✅ | +| Legacy System Archive เสร็จ (Compress + Store) | ✅ | --- ## 6. 🧑‍💼 Data Ownership & Responsibility -| Responsibility | Owner | Action | -|---------------|-------|--------| -| **Excel Metadata Quality** | Document Control (สค.) | ทำความสะอาดก่อน T-6 | -| **File Organization บน NAS** | Nattanin P. + IT | จัด Folder structure | -| **Organization Lookup Table** | Superadmin (NAP) | สร้างก่อน T-6 | -| **Tier 1 Document List** | Document Control ทุก Org | ยืนยัน T-5 | -| **Daily Monitoring (n8n Runs)** | Nattanin P. | T-3 ถึง Go-Live | -| **Admin Review Queue & AI Tag Approval** | Document Control (สค.) | ทุกเช้าวันทำงาน (บังคับตรวจสอบ New Tags) | -| **Post-migration Verification** | Nattanin P. | After each Gate | -| **Legacy System Archival** | กทท. IT + NAP | T+30 | +| Responsibility | Owner | Action | +| ---------------------------------------- | ------------------------ | ---------------------------------------- | +| **Excel Metadata Quality** | Document Control (สค.) | ทำความสะอาดก่อน T-6 | +| **File Organization บน NAS** | Nattanin P. + IT | จัด Folder structure | +| **Organization Lookup Table** | Superadmin (NAP) | สร้างก่อน T-6 | +| **Tier 1 Document List** | Document Control ทุก Org | ยืนยัน T-5 | +| **Daily Monitoring (n8n Runs)** | Nattanin P. | T-3 ถึง Go-Live | +| **Admin Review Queue & AI Tag Approval** | Document Control (สค.) | ทุกเช้าวันทำงาน (บังคับตรวจสอบ New Tags) | +| **Post-migration Verification** | Nattanin P. | After each Gate | +| **Legacy System Archival** | กทท. IT + NAP | T+30 | --- @@ -255,6 +260,7 @@ T+1 เดือน: - Output ของ AI ต้องผ่าน Backend Validation ก่อน Write 4. **Audit Log**: ทุก Record ที่ Import มี: + ```json { "created_by": "SYSTEM_IMPORT", "batch_id": "migration_YYYYMMDD", "action": "IMPORT" } ``` @@ -281,6 +287,7 @@ T+1 เดือน: ### กรณี Go-Live โดย Tier 2 ไม่เสร็จ (Emergency) **เปิดใช้ Parallel Operation:** + - ระบบใหม่: เอกสาร Tier 1 + เอกสารใหม่หลัง Go-Live - Legacy System: เปิด Read-only สำหรับเอกสาร Tier 2/3 ยังไม่ Migrate - Timeline Extension: Tier 2 ต้องเสร็จภายใน T+7 (ไม่เกิน 1 สัปดาห์หลัง Go-Live) @@ -289,15 +296,15 @@ T+1 เดือน: ## 9. 📊 Migration Success Metrics -| Metric | Target | วิธีวัด | -|--------|--------|--------| -| Total Records Imported | ≥ 95% ของ In-Scope | SQL COUNT vs Excel Row Count | -| Auto-import Rate (confidence ≥ 0.85) | ≥ 70% | n8n Execution Report | -| Review Queue Clearance | ≥ 95% ก่อน Go-Live | Review Queue Table | -| Reject Rate (Corrupted/Unreadable) | < 5% | Reject Log | -| Duplicate Records | 0 | SQL HAVING COUNT > 1 | -| Tag Extraction Rate | ≥ 80% ของ Auto-imported docs มี ≥ 1 Tag | SQL | -| Post-migration Search Hit Rate | ≥ 90% ของ Legacy doc numbers ค้นหาเจอ | Manual Test 100 samples | +| Metric | Target | วิธีวัด | +| ------------------------------------ | --------------------------------------- | ---------------------------- | +| Total Records Imported | ≥ 95% ของ In-Scope | SQL COUNT vs Excel Row Count | +| Auto-import Rate (confidence ≥ 0.85) | ≥ 70% | n8n Execution Report | +| Review Queue Clearance | ≥ 95% ก่อน Go-Live | Review Queue Table | +| Reject Rate (Corrupted/Unreadable) | < 5% | Reject Log | +| Duplicate Records | 0 | SQL HAVING COUNT > 1 | +| Tag Extraction Rate | ≥ 80% ของ Auto-imported docs มี ≥ 1 Tag | SQL | +| Post-migration Search Hit Rate | ≥ 90% ของ Legacy doc numbers ค้นหาเจอ | Manual Test 100 samples | --- diff --git a/specs/03-Data-and-Storage/03-07-OpenRAG.md b/specs/03-Data-and-Storage/03-07-OpenRAG.md index 1527c86..954d566 100644 --- a/specs/03-Data-and-Storage/03-07-OpenRAG.md +++ b/specs/03-Data-and-Storage/03-07-OpenRAG.md @@ -5,6 +5,7 @@ **Version:** 1.8.1 **Date:** 2026-03-13 **Related Documents:** + - [ADR-017: Ollama Data Migration](../06-Decision-Records/ADR-017-ollama-data-migration.md) - [ADR-018: AI Boundary Hardening](../06-Decision-Records/Patch%201.8.1.md) - [n8n Migration Setup Guide](./03-05-n8n-migration-setup-guide.md) @@ -12,12 +13,14 @@ - [OpenRAG (openr.ag)](https://www.openr.ag/) — IBM open-source RAG: Docling + OpenSearch + Langflow > ⚠️ **หมายเหตุ:** เอกสารนี้ออกแบบ RAG Pipeline **2 ส่วน**: +> > 1. **OpenRAG (Extraction Phase)** — ทำหน้าที่ "พนักงานคัดกรองข้อมูล" อ่าน PDF ทั้ง Folder แล้วเขียน JSON ลง `rag-output/` บน Shared NAS > 2. **n8n + Ollama + Elasticsearch (Integration & Search Phase)** — Poll ไฟล์ JSON จาก `rag-output/` ทีละไฟล์ แล้วนำเข้า DMS > > ทั้งหมดทำงาน **On-Premise** เท่านั้น — ไม่ส่งข้อมูลออกนอกเครือข่าย (ADR-018 AI Isolation) > > **Integration Model: File-based Queue (Pull)** +> > - Admin Desktop mount `R:\` (Drive Letter) → QNAP NAS Shared Folder (`staging_ai`) > - OpenRAG เขียน JSON ลง `R:\staging_ai\rag-output\` → n8n อ่านจาก `staging_ai/rag-output/` > - **ไม่มี HTTP ระหว่าง OpenRAG กับ n8n** — NAS Folder เป็น Shared Queue @@ -27,6 +30,7 @@ ## 🎯 วัตถุประสงค์ (Objective) เพิ่มความสามารถ **Semantic Search และ Document Q&A** ให้กับระบบ DMS โดยใช้ Infrastructure ที่มีอยู่แล้ว: + - ไม่ส่งข้อมูลออกนอกเครือข่ายองค์กร (Data Privacy) - ไม่มีค่าใช้จ่ายต่อ Query (Zero Cost) - ต่อยอดจากสถาปัตยกรรม Migration ที่ผ่าน Validate แล้ว (ADR-017/018) @@ -37,16 +41,16 @@ ตาม Patch 1.8.1 (ADR-018) Infrastructure Layout ที่กำหนดไว้: -| Component | Host | บทบาทใน RAG Pipeline | -| ---------------------- | ------------- | ------------------------------------------------- | -| **OpenRAG** (Docling + OpenSearch + Langflow) | Admin Desktop | **Phase 0: Extraction** — สกัด Metadata + Text จาก PDF เป็น JSON | -| **Tika** (Fallback OCR) | QNAP | สกัดข้อความจาก PDF กรณีไม่ใช้ OpenRAG หรือ Fallback | -| **Elasticsearch 8.11** | QNAP | Vector Store + Full-text Index | -| **n8n** | QNAP | Orchestrator — Poll JSON จาก `rag-output/` (ทีละไฟล์) แล้วนำเข้า DMS | -| **DMS Backend (NestJS)**| QNAP | API Gateway — รับ Query / ส่งผล / บันทึก Metadata | -| **Ollama** | Admin Desktop | AI Inference (Embedding + Generate) บน RTX 2060 SUPER | -| **MariaDB 11.8** | QNAP | Document Metadata (Authoritative DB) | -| **Redis 7.2** | QNAP | Cache (Query Result Cache) | +| Component | Host | บทบาทใน RAG Pipeline | +| --------------------------------------------- | ------------- | -------------------------------------------------------------------- | +| **OpenRAG** (Docling + OpenSearch + Langflow) | Admin Desktop | **Phase 0: Extraction** — สกัด Metadata + Text จาก PDF เป็น JSON | +| **Tika** (Fallback OCR) | QNAP | สกัดข้อความจาก PDF กรณีไม่ใช้ OpenRAG หรือ Fallback | +| **Elasticsearch 8.11** | QNAP | Vector Store + Full-text Index | +| **n8n** | QNAP | Orchestrator — Poll JSON จาก `rag-output/` (ทีละไฟล์) แล้วนำเข้า DMS | +| **DMS Backend (NestJS)** | QNAP | API Gateway — รับ Query / ส่งผล / บันทึก Metadata | +| **Ollama** | Admin Desktop | AI Inference (Embedding + Generate) บน RTX 2060 SUPER | +| **MariaDB 11.8** | QNAP | Document Metadata (Authoritative DB) | +| **Redis 7.2** | QNAP | Cache (Query Result Cache) | > ⛔ **ข้อห้าม (ADR-018):** OpenRAG และ Ollama **ห้ามอยู่บน QNAP** และห้ามเข้า DB โดยตรง > ✅ OpenRAG เขียนผล JSON ลง `rag-output/` บน Shared NAS (R:\ บน Admin Desktop = `staging_ai` บน QNAP) @@ -174,11 +178,11 @@ n8n ทำงานแบบ **Pull (Schedule-based)** — ดึง JSON ที > 📁 **File State Machine ใน `rag-output/`:** > -> | สถานะ | Filename | ความหมาย | -> |-------|----------|----------| -> | Pending | `TCC-COR-001.json` | รอ n8n ดึงไป Process | -> | Done | `TCC-COR-001.done` | นำเข้า DMS สำเร็จ | -> | Error | `TCC-COR-001.error` | ล้มเหลว — รอ Manual Review | +> | สถานะ | Filename | ความหมาย | +> | ------- | ------------------- | -------------------------- | +> | Pending | `TCC-COR-001.json` | รอ n8n ดึงไป Process | +> | Done | `TCC-COR-001.done` | นำเข้า DMS สำเร็จ | +> | Error | `TCC-COR-001.error` | ล้มเหลว — รอ Manual Review | ### Phase 2: Indexing Pipeline — สร้าง Vector Index ใน Elasticsearch @@ -267,9 +271,10 @@ PUT /dms_rag_chunks ``` > ⚠️ **ขนาด Embedding Vector:** ขึ้นอยู่กับ Model ที่ใช้ +> > - `nomic-embed-text`: 768 dims > - `llama3.2:3b` (ใช้ layer สุดท้าย): 3072 dims -> ต้องทดสอบ Performance บน RTX 2060 SUPER 8GB ก่อนเลือก +> ต้องทดสอบ Performance บน RTX 2060 SUPER 8GB ก่อนเลือก --- @@ -286,19 +291,19 @@ PUT /dms_rag_chunks Poll ไฟล์ JSON จาก Shared NAS ทีละไฟล์ แล้วนำข้อมูลเข้า DMS: -| Node | ชื่อ | หน้าที่ | -|------|------|----------| -| 0 | Schedule Trigger | ทำงานทุก 5 นาที (หรือ Manual Trigger) | -| 1 | List JSON Files | อ่านรายการ `staging_ai/rag-output/*.json` (กรอง .done/.error) | -| 2 | Loop Items | วนลูปทีละ 1 ไฟล์ | -| 3 | Read JSON File | อ่านเนื้อหา JSON จาก NAS | -| 4 | JSON Schema Validator | ตรวจสอบ field ครบ + ค่า is_valid | -| 5 | Confidence Router | แยก Auto / Review / Reject ตาม Threshold | -| 6A | Auto Ingest | POST `/api/migration/import` พร้อม Idempotency-Key | -| 6B | Review Queue | INSERT `migration_review_queue` เท่านั้น | -| 6C | Rename to .error | Rename ไฟล์ → `.error` + บันทึกเหตุผล | -| 7 | Rename to .done | Rename ไฟล์ → `.done` (กรณีสำเร็จ) | -| 8 | Save Checkpoint | UPDATE `migration_progress` ทุก 10 records | +| Node | ชื่อ | หน้าที่ | +| ---- | --------------------- | ------------------------------------------------------------- | +| 0 | Schedule Trigger | ทำงานทุก 5 นาที (หรือ Manual Trigger) | +| 1 | List JSON Files | อ่านรายการ `staging_ai/rag-output/*.json` (กรอง .done/.error) | +| 2 | Loop Items | วนลูปทีละ 1 ไฟล์ | +| 3 | Read JSON File | อ่านเนื้อหา JSON จาก NAS | +| 4 | JSON Schema Validator | ตรวจสอบ field ครบ + ค่า is_valid | +| 5 | Confidence Router | แยก Auto / Review / Reject ตาม Threshold | +| 6A | Auto Ingest | POST `/api/migration/import` พร้อม Idempotency-Key | +| 6B | Review Queue | INSERT `migration_review_queue` เท่านั้น | +| 6C | Rename to .error | Rename ไฟล์ → `.error` + บันทึกเหตุผล | +| 7 | Rename to .done | Rename ไฟล์ → `.done` (กรณีสำเร็จ) | +| 8 | Save Checkpoint | UPDATE `migration_progress` ทุก 10 records | --- @@ -306,37 +311,37 @@ Poll ไฟล์ JSON จาก Shared NAS ทีละไฟล์ แล้ Index Chunks (จาก OpenRAG JSON หรือ Tika Fallback) เข้า Elasticsearch: -| Node | ชื่อ | หน้าที่ | -|------|------|----------| -| 0 | Webhook / Schedule Trigger | รับ `doc_id` ที่นำเข้าแล้ว หรือ Batch รายคืน | -| 1 | Fetch Chunks | ดึง chunks จาก OpenRAG JSON หรือเรียก Tika Fallback | -| 2 | Tika OCR (Fallback) | POST `http://tika:9998/tika` กรณีไม่มี chunks จาก OpenRAG | -| 3 | Ollama Embeddings | POST `http://:11434/api/embeddings` | -| 4 | Elasticsearch Ingest | Bulk Index Chunks เข้า `dms_rag_chunks` | -| 5 | Update DMS Index Status | PATCH `/api/documents/{id}` ตั้ง `is_indexed: true` | +| Node | ชื่อ | หน้าที่ | +| ---- | -------------------------- | --------------------------------------------------------- | +| 0 | Webhook / Schedule Trigger | รับ `doc_id` ที่นำเข้าแล้ว หรือ Batch รายคืน | +| 1 | Fetch Chunks | ดึง chunks จาก OpenRAG JSON หรือเรียก Tika Fallback | +| 2 | Tika OCR (Fallback) | POST `http://tika:9998/tika` กรณีไม่มี chunks จาก OpenRAG | +| 3 | Ollama Embeddings | POST `http://:11434/api/embeddings` | +| 4 | Elasticsearch Ingest | Bulk Index Chunks เข้า `dms_rag_chunks` | +| 5 | Update DMS Index Status | PATCH `/api/documents/{id}` ตั้ง `is_indexed: true` | --- ## ⚙️ n8n Workflow: RAG Query (Node Overview) -| Node | ชื่อ | หน้าที่ | -|------|------|----------| -| 0 | Webhook | รับ `{ query, project_id, user_id, top_k }` จาก Backend | -| 1 | Ollama: Embed Query | แปลง Query เป็น Vector | -| 2 | Elasticsearch: kNN Search | ค้นหา Top-k Chunks พร้อม RBAC Filter | -| 3 | Build RAG Prompt | รวม Context Chunks + System Prompt + User Query | -| 4 | Ollama: Generate | สร้างคำตอบ, Output JSON เท่านั้น | -| 5 | Return to Backend | Respond Webhook พร้อม `{ answer, sources, confidence }` | +| Node | ชื่อ | หน้าที่ | +| ---- | ------------------------- | ------------------------------------------------------- | +| 0 | Webhook | รับ `{ query, project_id, user_id, top_k }` จาก Backend | +| 1 | Ollama: Embed Query | แปลง Query เป็น Vector | +| 2 | Elasticsearch: kNN Search | ค้นหา Top-k Chunks พร้อม RBAC Filter | +| 3 | Build RAG Prompt | รวม Context Chunks + System Prompt + User Query | +| 4 | Ollama: Generate | สร้างคำตอบ, Output JSON เท่านั้น | +| 5 | Return to Backend | Respond Webhook พร้อม `{ answer, sources, confidence }` | --- ## 📏 Confidence & Hallucination Guard -| ระดับ Confidence | การดำเนินการ | -|-----------------|--------------| -| `>= 0.80` | แสดงผลทันที พร้อม Sources | -| `0.60 – 0.79` | แสดงผลพร้อม Warning "โปรดตรวจสอบเอกสารต้นฉบับ" | -| `< 0.60` | ไม่แสดงคำตอบ — แสดงเฉพาะ Document Links ที่เกี่ยวข้อง | +| ระดับ Confidence | การดำเนินการ | +| ---------------- | ----------------------------------------------------- | +| `>= 0.80` | แสดงผลทันที พร้อม Sources | +| `0.60 – 0.79` | แสดงผลพร้อม Warning "โปรดตรวจสอบเอกสารต้นฉบับ" | +| `< 0.60` | ไม่แสดงคำตอบ — แสดงเฉพาะ Document Links ที่เกี่ยวข้อง | > AI ไม่มีสิทธิ์ Write ข้อมูลใดๆ — Output เป็น JSON Read-only เสมอ (ADR-018) @@ -344,20 +349,20 @@ Index Chunks (จาก OpenRAG JSON หรือ Tika Fallback) เข้า El ## 🚧 ข้อจำกัดและความเสี่ยง -| ความเสี่ยง | ผลกระทบ | Mitigation | -|-----------|----------|------------| -| NAS Drive R: disconnect ขณะ OpenRAG รัน | เขียน JSON ไม่ได้ | Langflow ตรวจ Drive ก่อนเริ่ม Loop — แจ้งเตือนถ้า mount หาย | -| ไฟล์ JSON เขียนไม่สมบูรณ์ (crash กลางคัน) | n8n อ่าน JSON เสีย | n8n ตรวจ JSON valid ก่อน Process — Rename → .error | -| OpenRAG Process PDF ซ้ำ (Retry) | JSON เขียนทับ | Skip ถ้า `.json` มีอยู่แล้ว (Idempotent by filename) | -| n8n อ่านไฟล์ขณะ OpenRAG ยังเขียนไม่เสร็จ | JSON ไม่สมบูรณ์ | OpenRAG เขียนเป็น `.tmp` ก่อน → Rename เป็น `.json` เมื่อเสร็จ | -| rag-output/ เต็ม (เก่าสะสม) | Disk บน NAS เต็ม | ตั้ง Schedule ลบ `.done` ที่เกิน 30 วัน | -| OpenRAG Metadata ผิด | นำข้อมูลผิดเข้า DMS | Confidence < 0.85 → Human Review Queue (ADR-017 Policy) | -| Embedding Dim Mismatch | Index ใช้งานไม่ได้ | กำหนด Model + Dims ก่อน Index แรก ห้ามเปลี่ยน | -| RTX 2060 SUPER VRAM (8GB) | Timeout ถ้า Model ใหญ่เกินไป | ใช้ `nomic-embed-text` สำหรับ Embedding | -| AI Hallucination | คำตอบผิด | Confidence Threshold + Source Citation บังคับ | -| Cross-project Data Leak | Security Issue | RBAC Filter ทุก Query ที่ Elasticsearch Layer | -| Elasticsearch Storage | Disk Usage สูง | เปิด ILM Policy หรือจำกัดเฉพาะ Project สำคัญ | -| Ollama ไม่พร้อม | Query ล้มเหลว | Graceful Fallback: ใช้ Elasticsearch Full-text เท่านั้น | +| ความเสี่ยง | ผลกระทบ | Mitigation | +| ----------------------------------------- | ---------------------------- | -------------------------------------------------------------- | +| NAS Drive R: disconnect ขณะ OpenRAG รัน | เขียน JSON ไม่ได้ | Langflow ตรวจ Drive ก่อนเริ่ม Loop — แจ้งเตือนถ้า mount หาย | +| ไฟล์ JSON เขียนไม่สมบูรณ์ (crash กลางคัน) | n8n อ่าน JSON เสีย | n8n ตรวจ JSON valid ก่อน Process — Rename → .error | +| OpenRAG Process PDF ซ้ำ (Retry) | JSON เขียนทับ | Skip ถ้า `.json` มีอยู่แล้ว (Idempotent by filename) | +| n8n อ่านไฟล์ขณะ OpenRAG ยังเขียนไม่เสร็จ | JSON ไม่สมบูรณ์ | OpenRAG เขียนเป็น `.tmp` ก่อน → Rename เป็น `.json` เมื่อเสร็จ | +| rag-output/ เต็ม (เก่าสะสม) | Disk บน NAS เต็ม | ตั้ง Schedule ลบ `.done` ที่เกิน 30 วัน | +| OpenRAG Metadata ผิด | นำข้อมูลผิดเข้า DMS | Confidence < 0.85 → Human Review Queue (ADR-017 Policy) | +| Embedding Dim Mismatch | Index ใช้งานไม่ได้ | กำหนด Model + Dims ก่อน Index แรก ห้ามเปลี่ยน | +| RTX 2060 SUPER VRAM (8GB) | Timeout ถ้า Model ใหญ่เกินไป | ใช้ `nomic-embed-text` สำหรับ Embedding | +| AI Hallucination | คำตอบผิด | Confidence Threshold + Source Citation บังคับ | +| Cross-project Data Leak | Security Issue | RBAC Filter ทุก Query ที่ Elasticsearch Layer | +| Elasticsearch Storage | Disk Usage สูง | เปิด ILM Policy หรือจำกัดเฉพาะ Project สำคัญ | +| Ollama ไม่พร้อม | Query ล้มเหลว | Graceful Fallback: ใช้ Elasticsearch Full-text เท่านั้น | --- @@ -367,6 +372,7 @@ Index Chunks (จาก OpenRAG JSON หรือ Tika Fallback) เข้า El > ต้องผ่าน Go-Live Gate ของ Migration (ADR-017) ก่อนเริ่มพัฒนา **OpenRAG Setup (Admin Desktop):** + - [ ] ติดตั้ง OpenRAG บน Admin Desktop ตาม `## 🛠️ OpenRAG Setup Guide` ด้านล่าง - [ ] กำหนด Langflow Workflow: PDF Input → Docling Parse → Ollama Extract → JSON Output - [ ] ตั้งค่า System Prompt ใน Langflow ให้ Output ตรง JSON Contract ด้านบน @@ -374,11 +380,13 @@ Index Chunks (จาก OpenRAG JSON หรือ Tika Fallback) เข้า El - [ ] ยืนยัน OpenRAG ไม่มี DB Credentials และ Mount `staging_ai` เป็น Read-only **n8n Webhook Integration:** + - [ ] สร้าง n8n Webhook Endpoint: รับ JSON จาก OpenRAG (validate schema + route ตาม Confidence) - [ ] ทดสอบ Idempotency-Key กรณี OpenRAG ส่ง Duplicate - [ ] สร้าง n8n Workflow: RAG Indexer (Dry Run กับ 10 เอกสาร) **Search & Query:** + - [ ] Migration v1.8.x เสร็จสมบูรณ์และ Stable (Prerequisite) - [ ] ทดสอบ `nomic-embed-text` บน Admin Desktop — วัด VRAM + Speed - [ ] กำหนด Elasticsearch Index Schema + Dims (lock ก่อน Index แรก) @@ -436,6 +444,7 @@ uv --version # ต้องแสดง version เช่น uv 0.5.x เมื่อรัน `uvx --python 3.13 openrag` ในขั้นตอนถัดไป `uv` จะ **ดาวน์โหลด Python 3.13 เองโดยอัตโนมัติ** ไม่ต้องติดตั้งแยก > **ทางเลือก:** ถ้าต้องการ Python 3.13 ระดับ System จริงๆ (ไม่บังคับ): +> > ```bash > sudo add-apt-repository ppa:deadsnakes/ppa -y > sudo apt update && sudo apt install -y python3.13 python3.13-venv @@ -461,20 +470,21 @@ uvx --with easyocr --python 3.13 openrag **ระหว่าง Interactive Setup ตอบดังนี้:** -| Prompt | คำตอบ (สำหรับระบบ LCBP3) | -|--------|--------------------------| -| OpenSearch Admin password | ตั้งรหัสผ่านแข็งแรง บันทึกไว้ | -| Langflow Admin password | ตั้งรหัสผ่านแข็งแรง บันทึกไว้ | -| OpenAI API key | **กด N / Skip** — เราใช้ Ollama แทน | -| Use custom LLM provider? | **Y** → เลือก **Ollama** | -| Ollama base URL | `http://192.168.20.100:11434` (Internal VLAN — Ollama รันบน Admin Desktop โดยตรง) | -| Configure Langfuse tracing? | **N** | -| Configure cloud connectors? | **N** | -| Start services now? | **Y** | +| Prompt | คำตอบ (สำหรับระบบ LCBP3) | +| --------------------------- | --------------------------------------------------------------------------------- | +| OpenSearch Admin password | ตั้งรหัสผ่านแข็งแรง บันทึกไว้ | +| Langflow Admin password | ตั้งรหัสผ่านแข็งแรง บันทึกไว้ | +| OpenAI API key | **กด N / Skip** — เราใช้ Ollama แทน | +| Use custom LLM provider? | **Y** → เลือก **Ollama** | +| Ollama base URL | `http://192.168.20.100:11434` (Internal VLAN — Ollama รันบน Admin Desktop โดยตรง) | +| Configure Langfuse tracing? | **N** | +| Configure cloud connectors? | **N** | +| Start services now? | **Y** | > ℹ️ **Ollama รันบน Windows โดยตรง** (ไม่ใช่ใน Docker) ที่ IP `192.168.20.100` — ตรงกับ Config ใน `03-05-n8n-migration-setup-guide.md` > > ถ้าตั้งค่าผิดพลาด แก้ไขได้ที่: +> > ```bash > nano ~/.openrag/tui/.env > # แก้บรรทัด OLLAMA_ENDPOINT=http://192.168.20.100:11434 @@ -496,11 +506,11 @@ docker ps **URL ที่ใช้งานได้:** -| Service | URL | หมายเหตุ | -|---------|-----|----------| +| Service | URL | หมายเหตุ | +| ---------- | ----------------------- | ------------------------- | | OpenRAG UI | `http://localhost:3000` | หน้าหลัก (เหมือน Chat UI) | -| Langflow | `http://localhost:7860` | สร้าง/แก้ไข Workflow | -| OpenSearch | `http://localhost:9200` | Vector Store API | +| Langflow | `http://localhost:7860` | สร้าง/แก้ไข Workflow | +| OpenSearch | `http://localhost:9200` | Vector Store API | --- @@ -547,12 +557,13 @@ ollama pull nomic-embed-text > **Component:** `Read File` (หมวด Data / Helpers) -| Setting | ค่า | -|---------|-----| -| Files | อัปโหลด หรือ ชี้ไปที่ `/data/staging_ai/` | -| Advanced Parser | `OFF` (ปิด — อ่านเป็น raw text ธรรมดา) | +| Setting | ค่า | +| --------------- | ----------------------------------------- | +| Files | อัปโหลด หรือ ชี้ไปที่ `/data/staging_ai/` | +| Advanced Parser | `OFF` (ปิด — อ่านเป็น raw text ธรรมดา) | **การเชื่อมต่อ:** + - Output `Files` → Input `Inputs` ของ Loop > ℹ️ Read File จะโหลดไฟล์ทั้งหมดมาเป็น list แล้วส่งให้ Loop วนลูปทีละไฟล์ @@ -564,11 +575,12 @@ ollama pull nomic-embed-text > **Component:** `Loop` (หมวด Logic) -| Setting | ค่า | -|---------|-----| -| Inputs | รับจาก `Read File → Files` | +| Setting | ค่า | +| ------- | -------------------------- | +| Inputs | รับจาก `Read File → Files` | **Output ที่ใช้:** + - `Item` → ส่งต่อให้ Parser และ Custom Code (filename) - `Done` → ไม่ต้องเชื่อมไปไหน (สัญญาณสิ้นสุด Loop) @@ -580,12 +592,13 @@ ollama pull nomic-embed-text > **Component:** `Parser` (หมวด Processing) -| Setting | ค่า | -|---------|-----| -| Mode | **`Stringify`** (ไม่ใช่ Parser) | -| Data or DataFrame | รับจาก `Loop → Item` | +| Setting | ค่า | +| ----------------- | ------------------------------- | +| Mode | **`Stringify`** (ไม่ใช่ Parser) | +| Data or DataFrame | รับจาก `Loop → Item` | **การเชื่อมต่อ:** + - Input `Data or DataFrame` ← `Loop → Item` - Output `Parsed Text` → Input `extracted_text` ของ Prompt Template @@ -599,12 +612,13 @@ ollama pull nomic-embed-text > **Component:** `Prompt Template` (หมวด Prompts) -| Setting | ค่า | -|---------|-----| -| Template | ใส่ System Prompt จากขั้นตอนที่ 7 ด้านล่าง | -| Variable `{extracted_text}` | เชื่อมกับ `Parser → Parsed Text` | +| Setting | ค่า | +| --------------------------- | ------------------------------------------ | +| Template | ใส่ System Prompt จากขั้นตอนที่ 7 ด้านล่าง | +| Variable `{extracted_text}` | เชื่อมกับ `Parser → Parsed Text` | **การเชื่อมต่อ:** + - Variable `extracted_text` ← `Parser → Parsed Text` - Output `Prompt` → Input `Input` ของ Ollama @@ -617,16 +631,17 @@ ollama pull nomic-embed-text > **Component:** `Ollama` (หมวด Models) -| Setting | ค่า | -|---------|-----| -| Ollama API URL | `http://localhost:11434` (ถ้ารันบน WSL ไม่ต้องใส่ IP) | -| Model Name | `scb10x/typhoon2.1-gemma3-4b` | -| Format | ไม่ต้องตั้ง — ใช้ Enable Structured Output แทน | -| Temperature | `0.1` | -| Enable Structured Output | `ON` | -| Tool Model Enabled | `ON` (เห็นใน screenshot) | +| Setting | ค่า | +| ------------------------ | ----------------------------------------------------- | +| Ollama API URL | `http://localhost:11434` (ถ้ารันบน WSL ไม่ต้องใส่ IP) | +| Model Name | `scb10x/typhoon2.1-gemma3-4b` | +| Format | ไม่ต้องตั้ง — ใช้ Enable Structured Output แทน | +| Temperature | `0.1` | +| Enable Structured Output | `ON` | +| Tool Model Enabled | `ON` (เห็นใน screenshot) | **การเชื่อมต่อ:** + - Input `Input` ← `Prompt Template → Prompt` - Input `System Message` ← ปล่อยว่าง (System Prompt อยู่ใน Prompt Template แล้ว) - Output `Text` → Input ของ Custom Code (Node 6) @@ -655,46 +670,46 @@ from pathlib import Path class WriteJsonIdempotent(Component): display_name = "Write JSON (Idempotent)" description = "Writes JSON to staging_ai dynamically based on loop item filename" - + inputs = [ StrInput(name="json_content", display_name="JSON Content"), DataInput(name="loop_item", display_name="Loop Item (PDF)"), ] - + outputs = [ Output(display_name="Result Path", name="result_path", method="write_file") ] - + def write_file(self) -> Data: # Extract filename from loop_item pdf_path = self.loop_item.data.get("file_path", "") if not pdf_path: return Data(data={"error": "No file_path in loop item"}) - + base_name = Path(pdf_path).stem out_dir = Path("/data/staging_ai/rag-output") out_dir.mkdir(parents=True, exist_ok=True) - + json_path = out_dir / f"{base_name}.json" - + # Idempotency check if json_path.exists(): return Data(data={"status": "skipped", "path": str(json_path), "reason": "already exists"}) - + # Parse and write content to ensure it's valid JSON before saving try: parsed = json.loads(self.json_content) # Inject source file name if missing if not parsed.get("source_file"): parsed["source_file"] = f"{base_name}.pdf" - + tmp_path = out_dir / f"{base_name}.tmp" with open(tmp_path, "w", encoding="utf-8") as f: json.dump(parsed, f, ensure_ascii=False, indent=2) - + # Atomic rename os.replace(tmp_path, json_path) - + return Data(data={"status": "written", "path": str(json_path)}) except Exception as e: err_path = out_dir / f"{base_name}.error" @@ -704,6 +719,7 @@ class WriteJsonIdempotent(Component): ``` **การเชื่อมต่อ:** + - Input `json_content` ← `Ollama → Text` - Input `loop_item` ← `Loop → Item` - Output `result_path` → `Loop → item` (Feedback loop กลับไปบอกว่ารอบนี้จบแล้ว) @@ -716,27 +732,26 @@ class WriteJsonIdempotent(Component): #### สรุปการ Wire ทั้ง Workflow -| From | Port | To | Port | -|------|------|----|------| -| Read File | Files | Loop | Inputs | -| Loop | Item | Parser | Data or DataFrame | -| Parser | Parsed Text | Prompt Template | extracted_text | -| Prompt Template | Prompt | Ollama | input_value (Input) | -| Ollama | Text | Write JSON (Idempotent) | json_content | -| Loop | Item | Write JSON (Idempotent) | loop_item | -| Write JSON | result_path | Loop | element | - +| From | Port | To | Port | +| --------------- | ----------- | ----------------------- | ------------------- | +| Read File | Files | Loop | Inputs | +| Loop | Item | Parser | Data or DataFrame | +| Parser | Parsed Text | Prompt Template | extracted_text | +| Prompt Template | Prompt | Ollama | input_value (Input) | +| Ollama | Text | Write JSON (Idempotent) | json_content | +| Loop | Item | Write JSON (Idempotent) | loop_item | +| Write JSON | result_path | Loop | element | **ตั้งค่า Ollama LLM Component:** -| ฟิลด์ | ค่า | -|-------|-----| -| Model Name | `scb10x/typhoon2.1-gemma3-4b` | -| Base URL | `http://192.168.20.100:11434` | -| Format | `json` (บังคับ JSON Output) | -| Temperature | `0.1` (ลด Hallucination) | -| Max Tokens | `2048` | -| Enable Structured Output | `ON` | +| ฟิลด์ | ค่า | +| ------------------------ | ----------------------------- | +| Model Name | `scb10x/typhoon2.1-gemma3-4b` | +| Base URL | `http://192.168.20.100:11434` | +| Format | `json` (บังคับ JSON Output) | +| Temperature | `0.1` (ลด Hallucination) | +| Max Tokens | `2048` | +| Enable Structured Output | `ON` | > ℹ️ **เหตุผลที่เลือก Typhoon 2.1:** > `scb10x/typhoon2.1-gemma3-4b` โดย SCB10X เป็น Model ที่ออกแบบมาสำหรับภาษาไทยโดยเฉพาะ @@ -804,7 +819,7 @@ services: volumes: # staging_ai mount จาก NAS # Windows R:\ drive จะปรากฏใน WSL เป็น /mnt/r/ - - /mnt/r/staging_ai:/data/staging_ai # ← Read PDF + Write rag-output/ + - /mnt/r/staging_ai:/data/staging_ai # ← Read PDF + Write rag-output/ # หมายเหตุ: ต้องเขียนได้ที่ rag-output/ จึงไม่ใส่ :ro opensearch: @@ -812,6 +827,7 @@ services: ``` > ⚠️ **ตรวจสอบ R:\ ใน WSL:** +> > ```bash > # ใน WSL Terminal ตรวจว่า mount อยู่ที่ไหน > ls /mnt/r/staging_ai/ @@ -819,6 +835,7 @@ services: > ``` > > ✅ **สร้าง rag-output/ ก่อนรัน:** +> > ```bash > mkdir -p /mnt/r/staging_ai/rag-output > ``` @@ -851,12 +868,12 @@ ls /mnt/r/staging_ai/rag-output/ สร้าง Test Workflow ใน n8n: -| Node | Type | Config | -|------|------|--------| -| Trigger | Manual | - | -| List Files | Read/Write Files from Disk | Path: `staging_ai/rag-output/*.json` | -| Read File | Read/Write Files from Disk | Dynamic path จาก List node | -| Parse JSON | Code | `JSON.parse(items[0].binary.data.toString())` | +| Node | Type | Config | +| ---------- | -------------------------- | --------------------------------------------- | +| Trigger | Manual | - | +| List Files | Read/Write Files from Disk | Path: `staging_ai/rag-output/*.json` | +| Read File | Read/Write Files from Disk | Dynamic path จาก List node | +| Parse JSON | Code | `JSON.parse(items[0].binary.data.toString())` | ```bash # ตรวจสอบ path ใน n8n container @@ -865,6 +882,7 @@ docker exec n8n ls /home/node/.n8n/staging_ai/rag-output/ ``` > 💡 **Path Mapping:** +> > - Admin Desktop (WSL): `/mnt/r/staging_ai/rag-output/` > - n8n บน QNAP: `staging_ai/rag-output/` (ตาม Volume Mount ใน docker-compose) @@ -872,18 +890,18 @@ docker exec n8n ls /home/node/.n8n/staging_ai/rag-output/ ### ขั้นตอนที่ 10: Pre-Production Verification -| ลำดับ | รายการ | วิธีตรวจสอบ | -|-------|--------|-------------| -| 1 | Ollama เชื่อมต่อได้ | `curl http://192.168.20.100:11434/api/tags` จาก WSL | -| 2 | `nomic-embed-text` พร้อม | `ollama list` บน Windows Terminal | -| 3 | Langflow รันได้ | เปิด `http://localhost:7860` | -| 4 | R:\ mount เห็น PDF | `ls /mnt/r/staging_ai/*.pdf` ใน WSL | -| 5 | Langflow เขียน rag-output/ ได้ | ดู `/mnt/r/staging_ai/rag-output/` หลังรัน Test | -| 6 | ไม่มี DB Credentials ใน env | ตรวจ `~/.openrag/tui/docker-compose.yml` | -| 7 | Extraction ถูกต้อง ≥ 85% | รัน Batch กับเอกสาร 20 ฉบับ นับ field ที่ถูก | -| 8 | JSON ถูกต้อง (valid JSON) | `python3 -m json.tool rag-output/test.json` | -| 9 | n8n อ่าน JSON จาก NAS ได้ | รัน Test Workflow ใน n8n ดู Execution Log | -| 10 | GPU VRAM < 7.5GB ระหว่างรัน | `nvidia-smi --query-gpu=memory.used --format=csv` | +| ลำดับ | รายการ | วิธีตรวจสอบ | +| ----- | ------------------------------ | --------------------------------------------------- | +| 1 | Ollama เชื่อมต่อได้ | `curl http://192.168.20.100:11434/api/tags` จาก WSL | +| 2 | `nomic-embed-text` พร้อม | `ollama list` บน Windows Terminal | +| 3 | Langflow รันได้ | เปิด `http://localhost:7860` | +| 4 | R:\ mount เห็น PDF | `ls /mnt/r/staging_ai/*.pdf` ใน WSL | +| 5 | Langflow เขียน rag-output/ ได้ | ดู `/mnt/r/staging_ai/rag-output/` หลังรัน Test | +| 6 | ไม่มี DB Credentials ใน env | ตรวจ `~/.openrag/tui/docker-compose.yml` | +| 7 | Extraction ถูกต้อง ≥ 85% | รัน Batch กับเอกสาร 20 ฉบับ นับ field ที่ถูก | +| 8 | JSON ถูกต้อง (valid JSON) | `python3 -m json.tool rag-output/test.json` | +| 9 | n8n อ่าน JSON จาก NAS ได้ | รัน Test Workflow ใน n8n ดู Execution Log | +| 10 | GPU VRAM < 7.5GB ระหว่างรัน | `nvidia-smi --query-gpu=memory.used --format=csv` | ```bash # ตรวจสอบ VRAM บน Admin Desktop (Windows Terminal) @@ -898,6 +916,7 @@ nvidia-smi --query-gpu=memory.used,memory.total --format=csv > ต้องผ่าน Go-Live Gate ของ Migration (ADR-017) ก่อนเริ่มพัฒนา **OpenRAG Setup (Admin Desktop):** + - [ ] WSL 2 + Docker Desktop ติดตั้งเสร็จ (ขั้นตอนที่ 1) - [ ] OpenRAG ติดตั้งผ่าน `uvx --python 3.13 openrag` (ขั้นตอนที่ 2–3) - [ ] Ollama เชื่อมต่อจาก Docker Container ได้ (ขั้นตอนที่ 5) @@ -910,6 +929,7 @@ nvidia-smi --query-gpu=memory.used,memory.total --format=csv - [ ] ยืนยัน OpenRAG ไม่มี DB Credentials ใน docker-compose.yml **n8n File-based Queue Integration:** + - [ ] ตรวจสอบ n8n Volume Mount เห็น `staging_ai/rag-output/` (ขั้นตอนที่ 9) - [ ] สร้าง n8n Schedule Workflow: List JSON Files → Loop → Read → Validate → Route - [ ] ทดสอบ Rename ไฟล์ `.json` → `.done` / `.error` ใน n8n @@ -917,6 +937,7 @@ nvidia-smi --query-gpu=memory.used,memory.total --format=csv - [ ] ทดสอบ Idempotency-Key กรณีรัน n8n ซ้ำ (ไฟล์ `.done` ไม่ถูก Process ซ้ำ) **Search & Query (Post-Migration):** + - [ ] Migration v1.8.x เสร็จสมบูรณ์และ Stable (Prerequisite) - [ ] กำหนด Elasticsearch Index Schema + Dims (lock ก่อน Index แรก) - [ ] ออกแบบ RBAC Filter สำหรับ kNN Search @@ -927,5 +948,5 @@ nvidia-smi --query-gpu=memory.used,memory.total --format=csv --- -*เอกสารนี้เป็น Living Document — อัปเดตเมื่อมีการตัดสินใจ Architecture ใหม่* +_เอกสารนี้เป็น Living Document — อัปเดตเมื่อมีการตัดสินใจ Architecture ใหม่_ **Version:** 1.8.1 | **Author:** Development Team | **Last Updated:** 2026-03-13 diff --git a/specs/03-Data-and-Storage/AI Prompt.js b/specs/03-Data-and-Storage/AI Prompt.js index 12e29f7..cb1baf7 100644 --- a/specs/03-Data-and-Storage/AI Prompt.js +++ b/specs/03-Data-and-Storage/AI Prompt.js @@ -4,15 +4,27 @@ const isFallback = fallbackState.is_fallback_active || false; const model = isFallback ? config.OLLAMA_MODEL_FALLBACK : config.OLLAMA_MODEL_PRIMARY; // Read DB Context -const dbContext = $('Fetch DB Context').all().map(i => i.json); -const dbProjects = dbContext.filter(d => d.type === 'projects').map(d => ({id: d.id, code: d.text1, name: d.text2})); -const dbDisciplines = dbContext.filter(d => d.type === 'disciplines').map(d => ({id: d.id, th: d.text1, en: d.text2})); -const dbOrgs = dbContext.filter(d => d.type === 'organizations').map(d => ({id: d.id, name: d.text1, code: d.text2})); -const dbTags = dbContext.filter(d => d.type === 'tags').map(d => ({id: d.id, name: d.text1})); -const dbCorrTypes = dbContext.filter(d => d.type === 'correspondence_types').map(d => ({id: d.id, code: d.text1, name: d.text2})); +const dbContext = $('Fetch DB Context') + .all() + .map((i) => i.json); +const dbProjects = dbContext + .filter((d) => d.type === 'projects') + .map((d) => ({ id: d.id, code: d.text1, name: d.text2 })); +const dbDisciplines = dbContext + .filter((d) => d.type === 'disciplines') + .map((d) => ({ id: d.id, th: d.text1, en: d.text2 })); +const dbOrgs = dbContext + .filter((d) => d.type === 'organizations') + .map((d) => ({ id: d.id, name: d.text1, code: d.text2 })); +const dbTags = dbContext.filter((d) => d.type === 'tags').map((d) => ({ id: d.id, name: d.text1 })); +const dbCorrTypes = dbContext + .filter((d) => d.type === 'correspondence_types') + .map((d) => ({ id: d.id, code: d.text1, name: d.text2 })); -let systemCategories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other']; -try { systemCategories = $('File Mount Check').first().json.system_categories || systemCategories; } catch (e) {} +let systemCategories = ['Correspondence', 'RFA', 'Drawing', 'Transmittal', 'Report', 'Other']; +try { + systemCategories = $('File Mount Check').first().json.system_categories || systemCategories; +} catch (e) {} const pdfItems = $('Extract PDF Text').all(); // File Validator passes all original Excel JSON fields through (sender, receiver, project_code, etc.) @@ -35,13 +47,13 @@ return pdfItems.map((pdfItem, i) => { // JavaScript pre-mapping const findOrgId = (code) => { if (!code) return null; - const match = dbOrgs.find(o => o.code === code || o.name === code); + const match = dbOrgs.find((o) => o.code === code || o.name === code); return match ? match.id : null; }; const findProjectId = (code) => { if (!code) return config.PROJECT_ID; // Fallback to config - const match = dbProjects.find(p => p.code === code || p.name === code); + const match = dbProjects.find((p) => p.code === code || p.name === code); return match ? match.id : config.PROJECT_ID; }; @@ -49,8 +61,8 @@ return pdfItems.map((pdfItem, i) => { const receiverId = findOrgId(receiverCode); const projectId = findProjectId(projectCode); // Excel corrType is likely already the ID based on requirements, but fallback matching to ID if needed - const corrMatch = dbCorrTypes.find(c => String(c.id) === corrType || c.code === corrType || c.name === corrType); - const corrTypeId = corrMatch ? corrMatch.id : (isNaN(parseInt(corrType)) ? null : parseInt(corrType)); + const corrMatch = dbCorrTypes.find((c) => String(c.id) === corrType || c.code === corrType || c.name === corrType); + const corrTypeId = corrMatch ? corrMatch.id : isNaN(parseInt(corrType)) ? null : parseInt(corrType); const isRFA = docNum.includes('-RFA-') || title.toLowerCase().includes('rfa'); @@ -60,7 +72,9 @@ Your task is to classify documents and extract metadata from OCR text. Respond ONLY with valid JSON.`; // Use pdfItem for the OCR extracted data, NOT the metaItem - const pdfText = String(pdfItem.json.data || '').substring(0, 3500).replace(/[^a-zA-Z0-9ก-๙\s\.\/\-:\[\]\(\)]/g, ' '); + const pdfText = String(pdfItem.json.data || '') + .substring(0, 3500) + .replace(/[^a-zA-Z0-9ก-๙\s\.\/\-:\[\]\(\)]/g, ' '); const userPrompt = `Analyze this document: [EXCEL METADATA] @@ -117,15 +131,15 @@ Respond ONLY with this EXACT JSON structure: project_id: projectId, sender_id: senderId, receiver_id: receiverId, - correspondence_type_id: corrTypeId + correspondence_type_id: corrTypeId, }, _debug_mapping: { excel_project_code: projectCode, excel_sender: senderCode, excel_receiver: receiverCode, excel_corr_type: corrType, - matched_project: dbProjects.find(p => p.code === projectCode || p.name === projectCode) || null, - first_org_sample: dbOrgs[0] || null + matched_project: dbProjects.find((p) => p.code === projectCode || p.name === projectCode) || null, + first_org_sample: dbOrgs[0] || null, }, ollama_payload: { model: model, @@ -134,9 +148,9 @@ Respond ONLY with this EXACT JSON structure: format: 'json', options: { temperature: 0.1, - num_ctx: 8192 - } - } - } + num_ctx: 8192, + }, + }, + }, }; }); diff --git a/specs/03-Data-and-Storage/OpenRAG V0.1.json b/specs/03-Data-and-Storage/OpenRAG V0.1.json index ec8fe0e..0ad2e87 100644 --- a/specs/03-Data-and-Storage/OpenRAG V0.1.json +++ b/specs/03-Data-and-Storage/OpenRAG V0.1.json @@ -9,16 +9,12 @@ "dataType": "File", "id": "File-5V2fL", "name": "dataframe", - "output_types": [ - "DataFrame" - ] + "output_types": ["DataFrame"] }, "targetHandle": { "fieldName": "data", "id": "LoopComponent-5vFOr", - "inputTypes": [ - "DataFrame" - ], + "inputTypes": ["DataFrame"], "type": "other" } }, @@ -37,16 +33,12 @@ "dataType": "Prompt Template", "id": "Prompt Template-dKwcS", "name": "prompt", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "system_message", "id": "OllamaModel-xJSnu", - "inputTypes": [ - "Message" - ], + "inputTypes": ["Message"], "type": "str" } }, @@ -64,17 +56,12 @@ "dataType": "LoopComponent", "id": "LoopComponent-5vFOr", "name": "item", - "output_types": [ - "Data" - ] + "output_types": ["Data"] }, "targetHandle": { "fieldName": "input_data", "id": "ParserComponent-Xspgr", - "inputTypes": [ - "DataFrame", - "Data" - ], + "inputTypes": ["DataFrame", "Data"], "type": "other" } }, @@ -92,16 +79,12 @@ "dataType": "ParserComponent", "id": "ParserComponent-Xspgr", "name": "parsed_text", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "extracted_text", "id": "Prompt Template-dKwcS", - "inputTypes": [ - "Message" - ], + "inputTypes": ["Message"], "type": "str" } }, @@ -119,18 +102,12 @@ "dataType": "OllamaModel", "id": "OllamaModel-xJSnu", "name": "text_output", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "input", "id": "SaveToFile-M0RUY", - "inputTypes": [ - "Data", - "DataFrame", - "Message" - ], + "inputTypes": ["Data", "DataFrame", "Message"], "type": "other" } }, @@ -148,18 +125,13 @@ "dataType": "SaveToFile", "id": "SaveToFile-M0RUY", "name": "message", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "dataType": "LoopComponent", "id": "LoopComponent-5vFOr", "name": "item", - "output_types": [ - "Data", - "Message" - ] + "output_types": ["Data", "Message"] } }, "id": "xy-edge__SaveToFile-M0RUY{œdataTypeœ:œSaveToFileœ,œidœ:œSaveToFile-M0RUYœ,œnameœ:œmessageœ,œoutput_typesœ:[œMessageœ]}-LoopComponent-5vFOr{œdataTypeœ:œLoopComponentœ,œidœ:œLoopComponent-5vFOrœ,œnameœ:œitemœ,œoutput_typesœ:[œDataœ,œMessageœ]}", @@ -175,9 +147,7 @@ "data": { "id": "File-5V2fL", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -258,9 +228,7 @@ "required_inputs": null, "selected": "DataFrame", "tool_mode": true, - "types": [ - "DataFrame" - ], + "types": ["DataFrame"], "value": "__UNDEFINED__" } ], @@ -438,9 +406,7 @@ "display_name": "Doc Key", "dynamic": false, "info": "The key to use for the DoclingDocument column.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -484,10 +450,7 @@ "display_name": "Server File Path", "dynamic": false, "info": "Data object with a 'file_path' property pointing to server file or a Message object with a path to the file. Supercedes 'Path' but supports same file types.", - "input_types": [ - "Data", - "Message" - ], + "input_types": ["Data", "Message"], "list": true, "list_add_label": "Add More", "name": "file_path", @@ -635,10 +598,7 @@ "external_options": {}, "info": "OCR engine to use. Only available when pipeline is set to 'standard'.", "name": "ocr_engine", - "options": [ - "None", - "easyocr" - ], + "options": ["None", "easyocr"], "options_metadata": [], "override_skip": false, "placeholder": "", @@ -772,10 +732,7 @@ "external_options": {}, "info": "Docling pipeline to use", "name": "pipeline", - "options": [ - "standard", - "vlm" - ], + "options": ["standard", "vlm"], "options_metadata": [], "override_skip": false, "placeholder": "", @@ -956,12 +913,7 @@ "data": { "id": "OllamaModel-xJSnu", "node": { - "base_classes": [ - "Data", - "DataFrame", - "LanguageModel", - "Message" - ], + "base_classes": ["Data", "DataFrame", "LanguageModel", "Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -1022,12 +974,7 @@ ], "total_dependencies": 3 }, - "keywords": [ - "model", - "llm", - "language model", - "large language model" - ], + "keywords": ["model", "llm", "language model", "large language model"], "module": "lfx.components.ollama.ollama.ChatOllamaComponent" }, "minimized": false, @@ -1045,9 +992,7 @@ "required_inputs": null, "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" }, { @@ -1061,9 +1006,7 @@ "options": null, "required_inputs": null, "tool_mode": true, - "types": [ - "LanguageModel" - ], + "types": ["LanguageModel"], "value": "__UNDEFINED__" }, { @@ -1077,9 +1020,7 @@ "options": null, "required_inputs": null, "tool_mode": true, - "types": [ - "Data" - ], + "types": ["Data"], "value": "__UNDEFINED__" }, { @@ -1093,9 +1034,7 @@ "options": null, "required_inputs": null, "tool_mode": true, - "types": [ - "DataFrame" - ], + "types": ["DataFrame"], "value": "__UNDEFINED__" } ], @@ -1249,13 +1188,7 @@ "edit_mode": "inline", "formatter": "text", "name": "type", - "options": [ - "str", - "int", - "float", - "bool", - "dict" - ], + "options": ["str", "int", "float", "bool", "dict"], "type": "str" }, { @@ -1265,10 +1198,7 @@ "edit_mode": "inline", "formatter": "text", "name": "multiple", - "options": [ - "True", - "False" - ], + "options": ["True", "False"], "type": "boolean" } ], @@ -1294,9 +1224,7 @@ "display_name": "Input", "dynamic": false, "info": "", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1344,11 +1272,7 @@ "external_options": {}, "info": "Enable/disable Mirostat sampling for controlling perplexity.", "name": "mirostat", - "options": [ - "Disabled", - "Mirostat", - "Mirostat 2.0" - ], + "options": ["Disabled", "Mirostat", "Mirostat 2.0"], "options_metadata": [], "override_skip": false, "placeholder": "", @@ -1413,10 +1337,7 @@ "external_options": {}, "info": "Refer to https://ollama.com/library for more models.", "name": "model_name", - "options": [ - "scb10x/typhoon2.1-gemma3-4b:latest", - "qwen2.5:7b-instruct-q4_K_M" - ], + "options": ["scb10x/typhoon2.1-gemma3-4b:latest", "qwen2.5:7b-instruct-q4_K_M"], "options_metadata": [], "override_skip": false, "placeholder": "", @@ -1538,9 +1459,7 @@ "display_name": "Stop Tokens", "dynamic": false, "info": "Comma-separated list of tokens to signal the model to stop generating text.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1583,9 +1502,7 @@ "display_name": "System", "dynamic": false, "info": "System to use for generating text.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1610,9 +1527,7 @@ "display_name": "System Message", "dynamic": false, "info": "System message to pass to the model.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1637,9 +1552,7 @@ "display_name": "Tags", "dynamic": false, "info": "Comma-separated list of tags to add to the run trace.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1692,9 +1605,7 @@ "display_name": "Template", "dynamic": false, "info": "Template to use for generating text.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1836,10 +1747,7 @@ "data": { "id": "LoopComponent-5vFOr", "node": { - "base_classes": [ - "Data", - "DataFrame" - ], + "base_classes": ["Data", "DataFrame"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -1847,9 +1755,7 @@ "display_name": "Loop", "documentation": "https://docs.langflow.org/loop", "edited": false, - "field_order": [ - "data" - ], + "field_order": ["data"], "frozen": false, "icon": "infinity", "legacy": false, @@ -1874,16 +1780,12 @@ "cache": true, "display_name": "Item", "group_outputs": true, - "loop_types": [ - "Message" - ], + "loop_types": ["Message"], "method": "item_output", "name": "item", "selected": "Data", "tool_mode": true, - "types": [ - "Data" - ], + "types": ["Data"], "value": "__UNDEFINED__" }, { @@ -1895,9 +1797,7 @@ "name": "done", "selected": "DataFrame", "tool_mode": true, - "types": [ - "DataFrame" - ], + "types": ["DataFrame"], "value": "__UNDEFINED__" } ], @@ -1928,9 +1828,7 @@ "display_name": "Inputs", "dynamic": false, "info": "The initial DataFrame to iterate over.", - "input_types": [ - "DataFrame" - ], + "input_types": ["DataFrame"], "list": false, "list_add_label": "Add More", "name": "data", @@ -1967,26 +1865,18 @@ "data": { "id": "Prompt Template-dKwcS", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": { - "template": [ - "extracted_text" - ] + "template": ["extracted_text"] }, "description": "Create a prompt template with dynamic variables.", "display_name": "Prompt Template", "documentation": "https://docs.langflow.org/components-prompts", "edited": false, "error": null, - "field_order": [ - "template", - "use_double_brackets", - "tool_placeholder" - ], + "field_order": ["template", "use_double_brackets", "tool_placeholder"], "frozen": false, "full_path": null, "icon": "prompts", @@ -2024,9 +1914,7 @@ "required_inputs": null, "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -2061,9 +1949,7 @@ "fileTypes": [], "file_path": "", "info": "", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "load_from_db": false, "multiline": true, @@ -2101,9 +1987,7 @@ "display_name": "Tool Placeholder", "dynamic": false, "info": "A placeholder input for tool mode.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2164,9 +2048,7 @@ "data": { "id": "ParserComponent-Xspgr", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -2174,12 +2056,7 @@ "display_name": "Parser", "documentation": "https://docs.langflow.org/parser", "edited": false, - "field_order": [ - "input_data", - "mode", - "pattern", - "sep" - ], + "field_order": ["input_data", "mode", "pattern", "sep"], "frozen": false, "icon": "braces", "last_updated": "2026-03-13T08:19:27.565Z", @@ -2212,9 +2089,7 @@ "required_inputs": null, "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -2271,10 +2146,7 @@ "display_name": "Data or DataFrame", "dynamic": false, "info": "Accepts either a DataFrame or a Data object.", - "input_types": [ - "DataFrame", - "Data" - ], + "input_types": ["DataFrame", "Data"], "list": false, "list_add_label": "Add More", "name": "input_data", @@ -2296,10 +2168,7 @@ "dynamic": false, "info": "Convert into raw string instead of using a template.", "name": "mode", - "options": [ - "Parser", - "Stringify" - ], + "options": ["Parser", "Stringify"], "override_skip": false, "placeholder": "", "real_time_refresh": true, @@ -2320,9 +2189,7 @@ "display_name": "Template", "dynamic": true, "info": "Use variables within curly brackets to extract column values for DataFrames or key values for Data.For example: `Name: {Name}, Age: {Age}, Country: {Country}`", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2347,9 +2214,7 @@ "display_name": "Separator", "dynamic": false, "info": "String used to separate rows/items.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2389,9 +2254,7 @@ "data": { "id": "SaveToFile-M0RUY", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -2472,9 +2335,7 @@ "required_inputs": null, "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -2695,16 +2556,7 @@ "external_options": {}, "info": "Select the file format for Google Drive storage.", "name": "gdrive_format", - "options": [ - "txt", - "json", - "csv", - "xlsx", - "slides", - "docs", - "jpg", - "mp3" - ], + "options": ["txt", "json", "csv", "xlsx", "slides", "docs", "jpg", "mp3"], "options_metadata": [], "override_skip": false, "placeholder": "", @@ -2724,11 +2576,7 @@ "display_name": "File Content", "dynamic": true, "info": "The input to save.", - "input_types": [ - "Data", - "DataFrame", - "Message" - ], + "input_types": ["Data", "DataFrame", "Message"], "list": false, "list_add_label": "Add More", "name": "input", @@ -2753,13 +2601,7 @@ "external_options": {}, "info": "Select the file format for local storage.", "name": "local_format", - "options": [ - "csv", - "excel", - "json", - "markdown", - "txt" - ], + "options": ["csv", "excel", "json", "markdown", "txt"], "options_metadata": [], "override_skip": false, "placeholder": "", diff --git a/specs/03-Data-and-Storage/OpenRAG V0.2.json b/specs/03-Data-and-Storage/OpenRAG V0.2.json index 1448460..8a479cc 100644 --- a/specs/03-Data-and-Storage/OpenRAG V0.2.json +++ b/specs/03-Data-and-Storage/OpenRAG V0.2.json @@ -9,16 +9,12 @@ "dataType": "File", "id": "File-5V2fL", "name": "dataframe", - "output_types": [ - "DataFrame" - ] + "output_types": ["DataFrame"] }, "targetHandle": { "fieldName": "data", "id": "LoopComponent-5vFOr", - "inputTypes": [ - "DataFrame" - ], + "inputTypes": ["DataFrame"], "type": "other" } }, @@ -37,16 +33,12 @@ "dataType": "Prompt Template", "id": "Prompt Template-dKwcS", "name": "prompt", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "input_value", "id": "OllamaModel-xJSnu", - "inputTypes": [ - "Message" - ], + "inputTypes": ["Message"], "type": "str" } }, @@ -64,17 +56,12 @@ "dataType": "LoopComponent", "id": "LoopComponent-5vFOr", "name": "item", - "output_types": [ - "Data" - ] + "output_types": ["Data"] }, "targetHandle": { "fieldName": "input_data", "id": "ParserComponent-Xspgr", - "inputTypes": [ - "DataFrame", - "Data" - ], + "inputTypes": ["DataFrame", "Data"], "type": "other" } }, @@ -92,16 +79,12 @@ "dataType": "ParserComponent", "id": "ParserComponent-Xspgr", "name": "parsed_text", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "extracted_text", "id": "Prompt Template-dKwcS", - "inputTypes": [ - "Message" - ], + "inputTypes": ["Message"], "type": "str" } }, @@ -119,18 +102,12 @@ "dataType": "OllamaModel", "id": "OllamaModel-xJSnu", "name": "text_output", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "fieldName": "json_content", "id": "CustomComponent-WriteJsonIdempotent", - "inputTypes": [ - "Data", - "DataFrame", - "Message" - ], + "inputTypes": ["Data", "DataFrame", "Message"], "type": "other" } }, @@ -148,18 +125,13 @@ "dataType": "CustomComponent", "id": "CustomComponent-WriteJsonIdempotent", "name": "result_path", - "output_types": [ - "Message" - ] + "output_types": ["Message"] }, "targetHandle": { "dataType": "LoopComponent", "id": "LoopComponent-5vFOr", "name": "item", - "output_types": [ - "Data", - "Message" - ] + "output_types": ["Data", "Message"] } }, "id": "xy-edge__CustomComponent-WriteJsonIdempotent{œdataTypeœ:œSaveToFileœ,œidœ:œCustomComponent-WriteJsonIdempotentœ,œnameœ:œmessageœ,œoutput_typesœ:[œMessageœ]}-LoopComponent-5vFOr{œdataTypeœ:œLoopComponentœ,œidœ:œLoopComponent-5vFOrœ,œnameœ:œitemœ,œoutput_typesœ:[œDataœ,œMessageœ]}", @@ -176,16 +148,12 @@ "dataType": "LoopComponent", "id": "LoopComponent-5vFOr", "name": "item", - "output_types": [ - "Data" - ] + "output_types": ["Data"] }, "targetHandle": { "fieldName": "loop_item", "id": "CustomComponent-WriteJsonIdempotent", - "inputTypes": [ - "Data" - ], + "inputTypes": ["Data"], "type": "Data" } }, @@ -199,9 +167,7 @@ "data": { "id": "File-5V2fL", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -282,9 +248,7 @@ "required_inputs": null, "selected": "DataFrame", "tool_mode": true, - "types": [ - "DataFrame" - ], + "types": ["DataFrame"], "value": "__UNDEFINED__" } ], @@ -462,9 +426,7 @@ "display_name": "Doc Key", "dynamic": false, "info": "The key to use for the DoclingDocument column.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -508,10 +470,7 @@ "display_name": "Server File Path", "dynamic": false, "info": "Data object with a 'file_path' property pointing to server file or a Message object with a path to the file. Supercedes 'Path' but supports same file types.", - "input_types": [ - "Data", - "Message" - ], + "input_types": ["Data", "Message"], "list": true, "list_add_label": "Add More", "name": "file_path", @@ -659,10 +618,7 @@ "external_options": {}, "info": "OCR engine to use. Only available when pipeline is set to 'standard'.", "name": "ocr_engine", - "options": [ - "None", - "easyocr" - ], + "options": ["None", "easyocr"], "options_metadata": [], "override_skip": false, "placeholder": "", @@ -796,10 +752,7 @@ "external_options": {}, "info": "Docling pipeline to use", "name": "pipeline", - "options": [ - "standard", - "vlm" - ], + "options": ["standard", "vlm"], "options_metadata": [], "override_skip": false, "placeholder": "", @@ -980,12 +933,7 @@ "data": { "id": "OllamaModel-xJSnu", "node": { - "base_classes": [ - "Data", - "DataFrame", - "LanguageModel", - "Message" - ], + "base_classes": ["Data", "DataFrame", "LanguageModel", "Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -1046,12 +994,7 @@ ], "total_dependencies": 3 }, - "keywords": [ - "model", - "llm", - "language model", - "large language model" - ], + "keywords": ["model", "llm", "language model", "large language model"], "module": "lfx.components.ollama.ollama.ChatOllamaComponent" }, "minimized": false, @@ -1069,9 +1012,7 @@ "required_inputs": null, "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" }, { @@ -1085,9 +1026,7 @@ "options": null, "required_inputs": null, "tool_mode": true, - "types": [ - "LanguageModel" - ], + "types": ["LanguageModel"], "value": "__UNDEFINED__" }, { @@ -1101,9 +1040,7 @@ "options": null, "required_inputs": null, "tool_mode": true, - "types": [ - "Data" - ], + "types": ["Data"], "value": "__UNDEFINED__" }, { @@ -1117,9 +1054,7 @@ "options": null, "required_inputs": null, "tool_mode": true, - "types": [ - "DataFrame" - ], + "types": ["DataFrame"], "value": "__UNDEFINED__" } ], @@ -1273,13 +1208,7 @@ "edit_mode": "inline", "formatter": "text", "name": "type", - "options": [ - "str", - "int", - "float", - "bool", - "dict" - ], + "options": ["str", "int", "float", "bool", "dict"], "type": "str" }, { @@ -1289,10 +1218,7 @@ "edit_mode": "inline", "formatter": "text", "name": "multiple", - "options": [ - "True", - "False" - ], + "options": ["True", "False"], "type": "boolean" } ], @@ -1318,9 +1244,7 @@ "display_name": "Input", "dynamic": false, "info": "", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1368,11 +1292,7 @@ "external_options": {}, "info": "Enable/disable Mirostat sampling for controlling perplexity.", "name": "mirostat", - "options": [ - "Disabled", - "Mirostat", - "Mirostat 2.0" - ], + "options": ["Disabled", "Mirostat", "Mirostat 2.0"], "options_metadata": [], "override_skip": false, "placeholder": "", @@ -1437,10 +1357,7 @@ "external_options": {}, "info": "Refer to https://ollama.com/library for more models.", "name": "model_name", - "options": [ - "scb10x/typhoon2.1-gemma3-4b:latest", - "qwen2.5:7b-instruct-q4_K_M" - ], + "options": ["scb10x/typhoon2.1-gemma3-4b:latest", "qwen2.5:7b-instruct-q4_K_M"], "options_metadata": [], "override_skip": false, "placeholder": "", @@ -1562,9 +1479,7 @@ "display_name": "Stop Tokens", "dynamic": false, "info": "Comma-separated list of tokens to signal the model to stop generating text.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1607,9 +1522,7 @@ "display_name": "System", "dynamic": false, "info": "System to use for generating text.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1635,9 +1548,7 @@ "display_name": "Tags", "dynamic": false, "info": "Comma-separated list of tags to add to the run trace.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1690,9 +1601,7 @@ "display_name": "Template", "dynamic": false, "info": "Template to use for generating text.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -1834,10 +1743,7 @@ "data": { "id": "LoopComponent-5vFOr", "node": { - "base_classes": [ - "Data", - "DataFrame" - ], + "base_classes": ["Data", "DataFrame"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -1845,9 +1751,7 @@ "display_name": "Loop", "documentation": "https://docs.langflow.org/loop", "edited": false, - "field_order": [ - "data" - ], + "field_order": ["data"], "frozen": false, "icon": "infinity", "legacy": false, @@ -1872,16 +1776,12 @@ "cache": true, "display_name": "Item", "group_outputs": true, - "loop_types": [ - "Message" - ], + "loop_types": ["Message"], "method": "item_output", "name": "item", "selected": "Data", "tool_mode": true, - "types": [ - "Data" - ], + "types": ["Data"], "value": "__UNDEFINED__" }, { @@ -1893,9 +1793,7 @@ "name": "done", "selected": "DataFrame", "tool_mode": true, - "types": [ - "DataFrame" - ], + "types": ["DataFrame"], "value": "__UNDEFINED__" } ], @@ -1926,9 +1824,7 @@ "display_name": "Inputs", "dynamic": false, "info": "The initial DataFrame to iterate over.", - "input_types": [ - "DataFrame" - ], + "input_types": ["DataFrame"], "list": false, "list_add_label": "Add More", "name": "data", @@ -1965,26 +1861,18 @@ "data": { "id": "Prompt Template-dKwcS", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": { - "template": [ - "extracted_text" - ] + "template": ["extracted_text"] }, "description": "Create a prompt template with dynamic variables.", "display_name": "Prompt Template", "documentation": "https://docs.langflow.org/components-prompts", "edited": false, "error": null, - "field_order": [ - "template", - "use_double_brackets", - "tool_placeholder" - ], + "field_order": ["template", "use_double_brackets", "tool_placeholder"], "frozen": false, "full_path": null, "icon": "prompts", @@ -2022,9 +1910,7 @@ "required_inputs": null, "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -2059,9 +1945,7 @@ "fileTypes": [], "file_path": "", "info": "", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "load_from_db": false, "multiline": true, @@ -2099,9 +1983,7 @@ "display_name": "Tool Placeholder", "dynamic": false, "info": "A placeholder input for tool mode.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2162,9 +2044,7 @@ "data": { "id": "ParserComponent-Xspgr", "node": { - "base_classes": [ - "Message" - ], + "base_classes": ["Message"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -2172,12 +2052,7 @@ "display_name": "Parser", "documentation": "https://docs.langflow.org/parser", "edited": false, - "field_order": [ - "input_data", - "mode", - "pattern", - "sep" - ], + "field_order": ["input_data", "mode", "pattern", "sep"], "frozen": false, "icon": "braces", "last_updated": "2026-03-13T08:19:27.565Z", @@ -2210,9 +2085,7 @@ "required_inputs": null, "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -2269,10 +2142,7 @@ "display_name": "Data or DataFrame", "dynamic": false, "info": "Accepts either a DataFrame or a Data object.", - "input_types": [ - "DataFrame", - "Data" - ], + "input_types": ["DataFrame", "Data"], "list": false, "list_add_label": "Add More", "name": "input_data", @@ -2294,10 +2164,7 @@ "dynamic": false, "info": "Convert into raw string instead of using a template.", "name": "mode", - "options": [ - "Parser", - "Stringify" - ], + "options": ["Parser", "Stringify"], "override_skip": false, "placeholder": "", "real_time_refresh": true, @@ -2318,9 +2185,7 @@ "display_name": "Template", "dynamic": true, "info": "Use variables within curly brackets to extract column values for DataFrames or key values for Data.For example: `Name: {Name}, Age: {Age}, Country: {Country}`", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2345,9 +2210,7 @@ "display_name": "Separator", "dynamic": false, "info": "String used to separate rows/items.", - "input_types": [ - "Message" - ], + "input_types": ["Message"], "list": false, "list_add_label": "Add More", "load_from_db": false, @@ -2387,10 +2250,7 @@ "data": { "id": "CustomComponent-WriteJsonIdempotent", "node": { - "base_classes": [ - "CustomComponent", - "Component" - ], + "base_classes": ["CustomComponent", "Component"], "beta": false, "conditional_paths": [], "custom_fields": {}, @@ -2471,9 +2331,7 @@ "required_inputs": null, "selected": "Message", "tool_mode": true, - "types": [ - "Message" - ], + "types": ["Message"], "value": "__UNDEFINED__" } ], @@ -2518,4 +2376,4 @@ "locked": false, "name": "OpenRAG V0.1", "tags": [] -} \ No newline at end of file +} diff --git a/specs/03-Data-and-Storage/fix_json.js b/specs/03-Data-and-Storage/fix_json.js index 5226834..e196bcd 100644 --- a/specs/03-Data-and-Storage/fix_json.js +++ b/specs/03-Data-and-Storage/fix_json.js @@ -2,9 +2,9 @@ const fs = require('fs'); let lines = fs.readFileSync('n8n.workflow.json', 'utf8').split('\n'); const toRemove = []; -for(let i = 0; i < lines.length; i++) { - if (lines[i].startsWith(" // Ollama Settings\\n OLLAMA_HOST:")) toRemove.push(i); - if (lines[i].startsWith("const model = isFallback ? config.OLLAMA_MODEL_FALLBACK")) toRemove.push(i); +for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith(' // Ollama Settings\\n OLLAMA_HOST:')) toRemove.push(i); + if (lines[i].startsWith('const model = isFallback ? config.OLLAMA_MODEL_FALLBACK')) toRemove.push(i); if (lines[i].startsWith(" response_to: String(meta.response_to || '').trim() || null,\\n")) toRemove.push(i); } diff --git a/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql b/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql index d578205..edbf1a9 100644 --- a/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql +++ b/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql @@ -1,4 +1,4 @@ --- ========================================================== +-- ========================================================== -- DMS v1.8.0 Schema Part 2/3: CREATE TABLE Statements -- รัน: mysql < 01-schema-drop.sql แล้วจึงรัน 02-schema-tables.sql -- ========================================================== @@ -105,6 +105,7 @@ CREATE TABLE refresh_tokens ( expires_at DATETIME NOT NULL COMMENT 'วันหมดอายุ', is_revoked TINYINT(1) DEFAULT 0 COMMENT 'สถานะยกเลิก (1=Revoked)', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', replaced_by_token VARCHAR(255) NULL COMMENT 'Token ใหม่ที่มาแทนที่ (Rotation)', FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บ Refresh Tokens สำหรับ Authentication'; diff --git a/specs/03-Data-and-Storage/n8n.workflow.json b/specs/03-Data-and-Storage/n8n.workflow.json index 2988b66..ff77515 100644 --- a/specs/03-Data-and-Storage/n8n.workflow.json +++ b/specs/03-Data-and-Storage/n8n.workflow.json @@ -42,10 +42,7 @@ "name": "Form Trigger", "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.2, - "position": [ - 31024, - 13504 - ], + "position": [31024, 13504], "webhookId": "e164a362-0c6b-4243-a5ad-b325aa943f4f", "notes": "เปิด URL เพื่อเลือก Model ก่อนรัน" }, @@ -57,10 +54,7 @@ "name": "Set Configuration", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [ - 31216, - 13504 - ], + "position": [31216, 13504], "notes": "กำหนดค่า Configuration ทั้งหมด - แก้ไขที่นี่ก่อนรัน" }, { @@ -83,10 +77,7 @@ "name": "Fetch Categories", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [ - 31216, - 13696 - ], + "position": [31216, 13696], "notes": "ดึง Categories จาก Backend" }, { @@ -109,10 +100,7 @@ "name": "Fetch Tags", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [ - 31392, - 13696 - ], + "position": [31392, 13696], "notes": "ดึง Tags ที่มีอยู่แล้วจาก Backend" }, { @@ -126,10 +114,7 @@ "name": "Check Backend Health", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [ - 31392, - 13504 - ], + "position": [31392, 13504], "onError": "continueErrorOutput", "notes": "ตรวจสอบ Backend พร้อมใช้งาน" }, @@ -141,10 +126,7 @@ "name": "File Mount Check", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [ - 31216, - 13904 - ], + "position": [31216, 13904], "notes": "ตรวจสอบ File System มีไฟล์ Excel และ Folder ตามตั้งค่า" }, { @@ -157,10 +139,7 @@ "name": "Read Checkpoint", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [ - 31632, - 13744 - ], + "position": [31632, 13744], "alwaysOutputData": true, "credentials": { "mySql": { @@ -180,10 +159,7 @@ "name": "Read Excel Binary", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, - "position": [ - 31392, - 13904 - ], + "position": [31392, 13904], "notes": "ดึงไฟล์ Excel ขึ้นมาไว้ในหน่วยความจำ" }, { @@ -194,10 +170,7 @@ "name": "Read Excel", "type": "n8n-nodes-base.spreadsheetFile", "typeVersion": 2, - "position": [ - 31392, - 14112 - ], + "position": [31392, 14112], "notes": "แปลงข้อมูล Excel เป็น JSON Data" }, { @@ -208,10 +181,7 @@ "name": "Process Batch + Encoding", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [ - 31808, - 13488 - ], + "position": [31808, 13488], "alwaysOutputData": true, "notes": "ตัด Batch + Normalize UTF-8" }, @@ -223,10 +193,7 @@ "name": "File Validator", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [ - 31984, - 13488 - ], + "position": [31984, 13488], "notes": "ตรวจสอบไฟล์ PDF ตัวชี้ใน Directory จาก Config" }, { @@ -239,10 +206,7 @@ "name": "Check Fallback State", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [ - 31792, - 13888 - ], + "position": [31792, 13888], "alwaysOutputData": true, "credentials": { "mySql": { @@ -261,10 +225,7 @@ "name": "Build AI Prompt", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [ - 32144, - 13872 - ], + "position": [32144, 13872], "notes": "สร้าง Prompt โดยใช้ Categories จาก System" }, { @@ -282,10 +243,7 @@ "name": "Ollama AI Analysis", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [ - 31792, - 14096 - ], + "position": [31792, 14096], "notes": "เรียก Ollama วิเคราะห์เอกสาร" }, { @@ -296,10 +254,7 @@ "name": "Parse & Validate AI Response", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [ - 32000, - 14096 - ], + "position": [32000, 14096], "notes": "Parse JSON + Validate Schema + Enum Check" }, { @@ -312,10 +267,7 @@ "name": "Update Fallback State", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [ - 32464, - 13472 - ], + "position": [32464, 13472], "credentials": { "mySql": { "id": "CHHfbKhMacNo03V4", @@ -332,10 +284,7 @@ "name": "Confidence Router", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [ - 32160, - 14096 - ], + "position": [32160, 14096], "notes": "แยกตาม Confidence: Auto(≥0.85) / Review(≥0.60) / Reject(<0.60)" }, { @@ -366,10 +315,7 @@ "name": "Import to Backend", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [ - 32704, - 13664 - ], + "position": [32704, 13664], "notes": "ส่งข้อมูลเข้า LCBP3 Backend พร้อม Idempotency-Key" }, { @@ -380,10 +326,7 @@ "name": "Flag Checkpoint", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [ - 32880, - 13664 - ], + "position": [32880, 13664], "notes": "กำหนดว่าจะบันทึก Checkpoint หรือไม่ (ทุก 10 records)" }, { @@ -396,10 +339,7 @@ "name": "Save Checkpoint", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [ - 32928, - 13856 - ], + "position": [32928, 13856], "credentials": { "mySql": { "id": "CHHfbKhMacNo03V4", @@ -418,10 +358,7 @@ "name": "Insert Review Queue", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [ - 32896, - 14016 - ], + "position": [32896, 14016], "credentials": { "mySql": { "id": "CHHfbKhMacNo03V4", @@ -438,10 +375,7 @@ "name": "Log Reject to CSV", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [ - 32624, - 14032 - ], + "position": [32624, 14032], "notes": "บันทึกรายการที่ถูกปฏิเสธลง CSV" }, { @@ -452,10 +386,7 @@ "name": "Log Error to CSV", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [ - 32448, - 14128 - ], + "position": [32448, 14128], "notes": "บันทึก Error ลง CSV (จาก File Validator)" }, { @@ -468,10 +399,7 @@ "name": "Log Error to DB", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [ - 32752, - 14128 - ], + "position": [32752, 14128], "credentials": { "mySql": { "id": "CHHfbKhMacNo03V4", @@ -489,10 +417,7 @@ "name": "Delay", "type": "n8n-nodes-base.wait", "typeVersion": 1, - "position": [ - 33104, - 14080 - ], + "position": [33104, 14080], "webhookId": "38e97a99-4dcc-4b63-977a-a02945a1c369", "notes": "หน่วงเวลาระหว่าง Batches" }, @@ -604,10 +529,7 @@ "name": "Route by Confidence", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, - "position": [ - 32336, - 13744 - ] + "position": [32336, 13744] }, { "parameters": { @@ -618,10 +540,7 @@ "name": "Read PDF File", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, - "position": [ - 31824, - 13680 - ], + "position": [31824, 13680], "onError": "continueErrorOutput" }, { @@ -665,10 +584,7 @@ "name": "Extract PDF Text", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [ - 32096, - 13664 - ], + "position": [32096, 13664], "onError": "continueErrorOutput" }, { @@ -681,10 +597,7 @@ "name": "Fetch DB Context", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [ - 32000, - 13872 - ], + "position": [32000, 13872], "alwaysOutputData": true, "credentials": { "mySql": { @@ -702,10 +615,7 @@ "name": "Build Import Payload", "typeVersion": 2, "type": "n8n-nodes-base.code", - "position": [ - 32544, - 13664 - ], + "position": [32544, 13664], "notes": "สร้าง payload สำหรับ Import to Backend" }, { @@ -716,10 +626,7 @@ "name": "Upsert Tags", "typeVersion": 2, "type": "n8n-nodes-base.code", - "position": [ - 32592, - 13856 - ], + "position": [32592, 13856], "notes": "Upsert tags หลัง import สำเร็จ" }, { @@ -732,10 +639,7 @@ "name": "Link Tags to Correspondence", "typeVersion": 2.4, "type": "n8n-nodes-base.mySql", - "position": [ - 32768, - 13856 - ], + "position": [32768, 13856], "credentials": { "mySql": { "id": "CHHfbKhMacNo03V4", diff --git a/specs/03-Data-and-Storage/test-db.js b/specs/03-Data-and-Storage/test-db.js index 4f31890..2615a73 100644 --- a/specs/03-Data-and-Storage/test-db.js +++ b/specs/03-Data-and-Storage/test-db.js @@ -6,7 +6,7 @@ async function test() { port: 3306, user: 'migration_bot', password: 'Center2025', - database: 'lcbp3' + database: 'lcbp3', }); try { diff --git a/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-db.yml b/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-db.yml index b7677cc..67259e1 100644 --- a/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-db.yml +++ b/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-db.yml @@ -6,10 +6,10 @@ x-restart: &restart_policy x-logging: &default_logging logging: - driver: "json-file" + driver: 'json-file' options: - max-size: "10m" - max-file: "5" + max-size: '10m' + max-file: '5' services: mariadb: @@ -21,31 +21,31 @@ services: deploy: resources: limits: - cpus: "2.0" + cpus: '2.0' memory: 4G reservations: - cpus: "0.5" + cpus: '0.5' memory: 1G command: >- --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci environment: - MYSQL_ROOT_PASSWORD: "Center#2025" - MYSQL_DATABASE: "lcbp3" - MYSQL_USER: "center" - MYSQL_PASSWORD: "Center#2025" - TZ: "Asia/Bangkok" + MYSQL_ROOT_PASSWORD: 'Center#2025' + MYSQL_DATABASE: 'lcbp3' + MYSQL_USER: 'center' + MYSQL_PASSWORD: 'Center#2025' + TZ: 'Asia/Bangkok' ports: - - "3306:3306" + - '3306:3306' networks: - lcbp3 volumes: - - "/share/np-dms/mariadb/data:/var/lib/mysql" - - "/share/np-dms/mariadb/my.cnf:/etc/mysql/conf.d/my.cnf:ro" - - "/share/np-dms/mariadb/init:/docker-entrypoint-initdb.d:ro" - - "/share/dms-data/mariadb/backup:/backup" + - '/share/np-dms/mariadb/data:/var/lib/mysql' + - '/share/np-dms/mariadb/my.cnf:/etc/mysql/conf.d/my.cnf:ro' + - '/share/np-dms/mariadb/init:/docker-entrypoint-initdb.d:ro' + - '/share/dms-data/mariadb/backup:/backup' healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'] interval: 10s timeout: 5s retries: 3 @@ -60,26 +60,26 @@ services: deploy: resources: limits: - cpus: "0.25" + cpus: '0.25' memory: 256M environment: - TZ: "Asia/Bangkok" - PMA_HOST: "mariadb" - PMA_PORT: "3306" - PMA_ABSOLUTE_URI: "https://pma.np-dms.work/" - UPLOAD_LIMIT: "1G" - MEMORY_LIMIT: "512M" + TZ: 'Asia/Bangkok' + PMA_HOST: 'mariadb' + PMA_PORT: '3306' + PMA_ABSOLUTE_URI: 'https://pma.np-dms.work/' + UPLOAD_LIMIT: '1G' + MEMORY_LIMIT: '512M' ports: - - "89:80" + - '89:80' networks: - lcbp3 # expose: # - "80" volumes: - - "/share/np-dms/pma/config.user.inc.php:/etc/phpmyadmin/config.user.inc.php:ro" - - "/share/np-dms/pma/zzz-custom.ini:/usr/local/etc/php/conf.d/zzz-custom.ini:ro" - - "/share/np-dms/pma/tmp:/var/lib/phpmyadmin/tmp:rw" - - "/share/dms-data/logs/pma:/var/log/apache2" + - '/share/np-dms/pma/config.user.inc.php:/etc/phpmyadmin/config.user.inc.php:ro' + - '/share/np-dms/pma/zzz-custom.ini:/usr/local/etc/php/conf.d/zzz-custom.ini:ro' + - '/share/np-dms/pma/tmp:/var/lib/phpmyadmin/tmp:rw' + - '/share/dms-data/logs/pma:/var/log/apache2' depends_on: mariadb: condition: service_healthy diff --git a/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-git.yml b/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-git.yml index 594ef34..c982349 100644 --- a/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-git.yml +++ b/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-git.yml @@ -17,37 +17,37 @@ services: tty: true environment: # ---- File ownership in QNAP ---- - USER_UID: "1000" - USER_GID: "1000" + USER_UID: '1000' + USER_GID: '1000' TZ: Asia/Bangkok # ---- Server / Reverse proxy (NPM) ---- GITEA__server__ROOT_URL: https://git.np-dms.work/ GITEA__server__DOMAIN: git.np-dms.work GITEA__server__SSH_DOMAIN: git.np-dms.work - GITEA__server__START_SSH_SERVER: "true" - GITEA__server__SSH_PORT: "22" - GITEA__server__SSH_LISTEN_PORT: "22" - GITEA__server__LFS_START_SERVER: "true" - GITEA__server__HTTP_ADDR: "0.0.0.0" - GITEA__server__HTTP_PORT: "3000" - GITEA__server__TRUSTED_PROXIES: "127.0.0.1/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" + GITEA__server__START_SSH_SERVER: 'true' + GITEA__server__SSH_PORT: '22' + GITEA__server__SSH_LISTEN_PORT: '22' + GITEA__server__LFS_START_SERVER: 'true' + GITEA__server__HTTP_ADDR: '0.0.0.0' + GITEA__server__HTTP_PORT: '3000' + GITEA__server__TRUSTED_PROXIES: '127.0.0.1/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16' # --- การตั้งค่าฐานข้อมูล GITEA__database__DB_TYPE: mysql GITEA__database__HOST: mariadb:3306 - GITEA__database__NAME: "gitea" - GITEA__database__USER: "gitea" - GITEA__database__PASSWD: "Center#2025" + GITEA__database__NAME: 'gitea' + GITEA__database__USER: 'gitea' + GITEA__database__PASSWD: 'Center#2025' # --- repos GITEA__repository__ROOT: /var/lib/gitea/git/repositories - DISABLE_HTTP_GIT: "false" - ENABLE_BASIC_AUTHENTICATION: "true" + DISABLE_HTTP_GIT: 'false' + ENABLE_BASIC_AUTHENTICATION: 'true' # --- Enable Package Registry --- - GITEA__packages__ENABLED: "true" - GITEA__packages__REGISTRY__ENABLED: "true" + GITEA__packages__ENABLED: 'true' + GITEA__packages__REGISTRY__ENABLED: 'true' GITEA__packages__REGISTRY__STORAGE_TYPE: local GITEA__packages__REGISTRY__STORAGE_PATH: /data/registry # Optional: lock install after setup (เปลี่ยนเป็น true เมื่อจบ onboarding) - GITEA__security__INSTALL_LOCK: "true" + GITEA__security__INSTALL_LOCK: 'true' volumes: - /share/np-dms/gitea/backup:/backup - /share/np-dms/gitea/etc:/etc/gitea @@ -58,12 +58,11 @@ services: - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro ports: - - "3003:3000" # HTTP (ไปหลัง NPM) - - "2222:22" # SSH สำหรับ git clone/push + - '3003:3000' # HTTP (ไปหลัง NPM) + - '2222:22' # SSH สำหรับ git clone/push networks: - lcbp3 - giteanet - # networks: # gitea_net: # driver: bridge diff --git a/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-n8n.yml b/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-n8n.yml index 4cb9fbf..c81fec7 100644 --- a/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-n8n.yml +++ b/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-n8n.yml @@ -5,10 +5,10 @@ x-restart: &restart_policy x-logging: &default_logging logging: - driver: "json-file" + driver: 'json-file' options: - max-size: "10m" - max-file: "5" + max-size: '10m' + max-file: '5' services: n8n-db: <<: [*restart_policy, *default_logging] @@ -19,7 +19,7 @@ services: - POSTGRES_PASSWORD=Np721220$ - POSTGRES_DB=n8n volumes: - - "/share/np-dms/n8n/postgres-data:/var/lib/postgresql/data" + - '/share/np-dms/n8n/postgres-data:/var/lib/postgresql/data' networks: lcbp3: {} healthcheck: @@ -32,7 +32,7 @@ services: <<: [*restart_policy, *default_logging] image: apache/tika:latest-full container_name: tika - user: "root" + user: 'root' environment: - TESSDATA_PREFIX=/tessdata volumes: @@ -40,7 +40,7 @@ services: networks: lcbp3: {} expose: - - "9998" + - '9998' n8n: <<: [*restart_policy, *default_logging] @@ -56,33 +56,33 @@ services: deploy: resources: limits: - cpus: "1.5" + cpus: '1.5' memory: 3G reservations: - cpus: "0.25" + cpus: '0.25' memory: 512M environment: - TZ: "Asia/Bangkok" - NODE_ENV: "production" + TZ: 'Asia/Bangkok' + NODE_ENV: 'production' # N8N_PATH: "/n8n/" - N8N_PUBLIC_URL: "https://n8n.np-dms.work/" - WEBHOOK_URL: "https://n8n.np-dms.work/" - N8N_EDITOR_BASE_URL: "https://n8n.np-dms.work/" - N8N_PROTOCOL: "https" - N8N_HOST: "n8n.np-dms.work" + N8N_PUBLIC_URL: 'https://n8n.np-dms.work/' + WEBHOOK_URL: 'https://n8n.np-dms.work/' + N8N_EDITOR_BASE_URL: 'https://n8n.np-dms.work/' + N8N_PROTOCOL: 'https' + N8N_HOST: 'n8n.np-dms.work' N8N_PORT: 5678 - N8N_PROXY_HOPS: "1" + N8N_PROXY_HOPS: '1' N8N_DIAGNOSTICS_ENABLED: 'false' N8N_SECURE_COOKIE: 'true' - N8N_ENCRYPTION_KEY: "9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI" + N8N_ENCRYPTION_KEY: '9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI' # File access control for "Read/Write Files from Disk" nodes # Ref: https://github.com/n8n-io/n8n/blob/master/packages/@n8n/config/src/configs/security.config.ts # Default is "~/.n8n-files". Separate multiple dirs with semicolon (;) - N8N_RESTRICT_FILE_ACCESS_TO: "/home/node/.n8n-files" - N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES: "false" - GENERIC_TIMEZONE: "Asia/Bangkok" - NODE_FUNCTION_ALLOW_BUILTIN: "*" - NODES_EXCLUDE: "[]" + N8N_RESTRICT_FILE_ACCESS_TO: '/home/node/.n8n-files' + N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES: 'false' + GENERIC_TIMEZONE: 'Asia/Bangkok' + NODE_FUNCTION_ALLOW_BUILTIN: '*' + NODES_EXCLUDE: '[]' # DB Setup DB_TYPE: postgresdb DB_POSTGRESDB_DATABASE: n8n @@ -96,22 +96,22 @@ services: # EXECUTIONS_DATA_PRUNE_TIMEOUT: 60 ports: - - "5678:5678" + - '5678:5678' networks: lcbp3: {} volumes: - - "/share/np-dms/n8n:/home/node/.n8n" - - "/share/np-dms/n8n/cache:/home/node/.cache" - - "/share/np-dms/n8n/scripts:/scripts" - - "/share/np-dms/n8n/data:/data" - - "/var/run/docker.sock:/var/run/docker.sock" + - '/share/np-dms/n8n:/home/node/.n8n' + - '/share/np-dms/n8n/cache:/home/node/.cache' + - '/share/np-dms/n8n/scripts:/scripts' + - '/share/np-dms/n8n/data:/data' + - '/var/run/docker.sock:/var/run/docker.sock' # read-only: อ่านไฟล์ PDF ต้นฉบับเท่านั้น - - "/share/np-dms-as/Legacy:/home/node/.n8n-files/staging_ai:ro" # Add alias for np-dms-as to match the node setting + - '/share/np-dms-as/Legacy:/home/node/.n8n-files/staging_ai:ro' # Add alias for np-dms-as to match the node setting # read-write: เขียน Log และ CSV ทั้งหมด - - "/share/np-dms/n8n/migration_logs:/home/node/.n8n-files/migration_logs:rw" + - '/share/np-dms/n8n/migration_logs:/home/node/.n8n-files/migration_logs:rw' healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5678/healthz || exit 1"] + test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:5678/healthz || exit 1'] interval: 30s timeout: 10s start_period: 60s @@ -120,7 +120,6 @@ services: networks: lcbp3: external: true - # สำหรับ n8n volumes # chown -R 1000:1000 /share/np-dms/n8n # chmod -R 755 /share/np-dms/n8n3 diff --git a/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-npm.yml b/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-npm.yml index 9ebc8c7..170ba0b 100644 --- a/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-npm.yml +++ b/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-npm.yml @@ -6,10 +6,10 @@ x-restart: &restart_policy x-logging: &default_logging logging: - driver: "json-file" + driver: 'json-file' options: - max-size: "10m" - max-file: "5" + max-size: '10m' + max-file: '5' services: npm: <<: [*restart_policy, *default_logging] @@ -20,45 +20,44 @@ services: deploy: resources: limits: - cpus: "1.0" # 50% CPU + cpus: '1.0' # 50% CPU memory: 512M ports: - - "80:80" # HTTP - - "443:443" # HTTPS - - "81:81" # NPM Admin UI + - '80:80' # HTTP + - '443:443' # HTTPS + - '81:81' # NPM Admin UI environment: - TZ: "Asia/Bangkok" - DB_MYSQL_HOST: "mariadb" + TZ: 'Asia/Bangkok' + DB_MYSQL_HOST: 'mariadb' DB_MYSQL_PORT: 3306 - DB_MYSQL_USER: "npm" - DB_MYSQL_PASSWORD: "npm" - DB_MYSQL_NAME: "npm" + DB_MYSQL_USER: 'npm' + DB_MYSQL_PASSWORD: 'npm' + DB_MYSQL_NAME: 'npm' # Uncomment this if IPv6 is not enabled on your host - DISABLE_IPV6: "true" + DISABLE_IPV6: 'true' networks: - lcbp3 - giteanet volumes: - - "/share/np-dms/npm/data:/data" - - "/share/dms-data/logs/npm:/data/logs" # <-- เพิ่ม logging volume - - "/share/np-dms/npm/letsencrypt:/etc/letsencrypt" - - "/share/np-dms/npm/custom:/data/nginx/custom" # <-- สำคัญสำหรับ http_top.conf + - '/share/np-dms/npm/data:/data' + - '/share/dms-data/logs/npm:/data/logs' # <-- เพิ่ม logging volume + - '/share/np-dms/npm/letsencrypt:/etc/letsencrypt' + - '/share/np-dms/npm/custom:/data/nginx/custom' # <-- สำคัญสำหรับ http_top.conf # - "/share/Container/lcbp3/npm/landing:/data/landing:ro" landing: - image: nginx:1.27-alpine - container_name: landing - restart: unless-stopped - volumes: - - "/share/np-dms/npm/landing:/usr/share/nginx/html:ro" - networks: - - lcbp3 + image: nginx:1.27-alpine + container_name: landing + restart: unless-stopped + volumes: + - '/share/np-dms/npm/landing:/usr/share/nginx/html:ro' + networks: + - lcbp3 networks: lcbp3: external: true giteanet: external: true name: gitnet - # docker exec -it npm id # chown -R 0:0 /share/Container/npm # setfacl -R -m u:0:rwx /share/Container/npm diff --git a/specs/04-Infrastructure-OPS/04-00-docker-compose/lcbp3-monitoring.yml b/specs/04-Infrastructure-OPS/04-00-docker-compose/lcbp3-monitoring.yml index 75719f0..83619c6 100644 --- a/specs/04-Infrastructure-OPS/04-00-docker-compose/lcbp3-monitoring.yml +++ b/specs/04-Infrastructure-OPS/04-00-docker-compose/lcbp3-monitoring.yml @@ -8,10 +8,10 @@ x-restart: &restart_policy x-logging: &default_logging logging: - driver: "json-file" + driver: 'json-file' options: - max-size: "10m" - max-file: "5" + max-size: '10m' + max-file: '5' networks: lcbp3: @@ -30,27 +30,27 @@ services: deploy: resources: limits: - cpus: "1.0" + cpus: '1.0' memory: 1G reservations: - cpus: "0.25" + cpus: '0.25' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--storage.tsdb.retention.time=30d' - '--web.enable-lifecycle' ports: - - "9090:9090" + - '9090:9090' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/prometheus/config:/etc/prometheus:ro" - - "/volume1/np-dms/monitoring/prometheus/data:/prometheus" + - '/volume1/np-dms/monitoring/prometheus/config:/etc/prometheus:ro' + - '/volume1/np-dms/monitoring/prometheus/data:/prometheus' healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/healthy"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:9090/-/healthy'] interval: 30s timeout: 10s retries: 3 @@ -67,27 +67,27 @@ services: deploy: resources: limits: - cpus: "1.0" + cpus: '1.0' memory: 512M reservations: - cpus: "0.25" + cpus: '0.25' memory: 128M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' GF_SECURITY_ADMIN_USER: admin - GF_SECURITY_ADMIN_PASSWORD: "Center#2025" - GF_SERVER_ROOT_URL: "https://grafana.np-dms.work" + GF_SECURITY_ADMIN_PASSWORD: 'Center#2025' + GF_SERVER_ROOT_URL: 'https://grafana.np-dms.work' GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-piechart-panel ports: - - "3000:3000" + - '3000:3000' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/grafana/data:/var/lib/grafana" + - '/volume1/np-dms/monitoring/grafana/data:/var/lib/grafana' depends_on: - prometheus healthcheck: - test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/api/health || exit 1"] + test: ['CMD-SHELL', 'wget --spider -q http://localhost:3000/api/health || exit 1'] interval: 30s timeout: 10s retries: 3 @@ -102,18 +102,18 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' ports: - - "3001:3001" + - '3001:3001' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/uptime-kuma/data:/app/data" + - '/volume1/np-dms/monitoring/uptime-kuma/data:/app/data' healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3001/api/entry-page || exit 1"] + test: ['CMD-SHELL', 'curl -f http://localhost:3001/api/entry-page || exit 1'] interval: 30s timeout: 10s retries: 3 @@ -128,16 +128,16 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 128M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: - '--path.procfs=/host/proc' - '--path.sysfs=/host/sys' - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' ports: - - "9100:9100" + - '9100:9100' networks: - lcbp3 volumes: @@ -145,7 +145,7 @@ services: - /sys:/host/sys:ro - /:/rootfs:ro healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:9100/metrics"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:9100/metrics'] interval: 30s timeout: 10s retries: 3 @@ -160,12 +160,12 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' ports: - - "8088:8088" + - '8088:8088' networks: - lcbp3 volumes: @@ -174,7 +174,7 @@ services: - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:8080/healthz'] interval: 30s timeout: 10s retries: 3 @@ -189,19 +189,19 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 512M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: -config.file=/etc/loki/local-config.yaml ports: - - "3100:3100" + - '3100:3100' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/loki/data:/loki" + - '/volume1/np-dms/monitoring/loki/data:/loki' healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:3100/ready"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3100/ready'] interval: 30s timeout: 10s retries: 3 @@ -213,20 +213,20 @@ services: <<: [*restart_policy, *default_logging] image: grafana/promtail:2.9.0 container_name: promtail - user: "0:0" + user: '0:0' deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: -config.file=/etc/promtail/promtail-config.yml networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/promtail/config:/etc/promtail:ro" - - "/var/run/docker.sock:/var/run/docker.sock:ro" - - "/var/lib/docker/containers:/var/lib/docker/containers:ro" + - '/volume1/np-dms/monitoring/promtail/config:/etc/promtail:ro' + - '/var/run/docker.sock:/var/run/docker.sock:ro' + - '/var/lib/docker/containers:/var/lib/docker/containers:ro' depends_on: - - loki \ No newline at end of file + - loki diff --git a/specs/04-Infrastructure-OPS/04-01-docker-compose.md b/specs/04-Infrastructure-OPS/04-01-docker-compose.md index 37b1089..b6cfbf4 100644 --- a/specs/04-Infrastructure-OPS/04-01-docker-compose.md +++ b/specs/04-Infrastructure-OPS/04-01-docker-compose.md @@ -1,4 +1,5 @@ # 04.1 Infrastructure Setup & Docker Compose + **Project:** LCBP3-DMS **Version:** 1.8.0 **Status:** Active @@ -396,9 +397,7 @@ Backend validates environment variables at startup: import * as Joi from 'joi'; export const envValidationSchema = Joi.object({ - NODE_ENV: Joi.string() - .valid('development', 'staging', 'production') - .required(), + NODE_ENV: Joi.string().valid('development', 'staging', 'production').required(), DB_HOST: Joi.string().required(), DB_PORT: Joi.number().default(3306), DB_USER: Joi.string().required(), @@ -480,7 +479,6 @@ docker exec lcbp3-backend env | grep NODE_ENV **Last Review:** 2025-12-01 **Next Review:** 2026-03-01 - --- # Infrastructure Setup @@ -502,6 +500,7 @@ docker exec lcbp3-backend env | grep NODE_ENV ## 1. Redis Configuration (Standalone + Persistence) ### 1.1 Docker Compose Setup + ```yaml # docker-compose-redis.yml version: '3.8' @@ -530,10 +529,10 @@ networks: external: true ``` - ## 2. Database Configuration ### 2.1 MariaDB Optimization for Numbering + ```sql -- /etc/mysql/mariadb.conf.d/50-numbering.cnf @@ -568,6 +567,7 @@ long_query_time = 1 ``` ### 2.2 Monitoring Locks + ```sql -- Check for lock contention SELECT @@ -595,6 +595,7 @@ KILL ; ### 3.1 Backend Service Deployment #### Docker Compose + ```yaml # docker-compose-backend.yml version: '3.8' @@ -611,7 +612,7 @@ services: - NUMBERING_LOCK_TIMEOUT=5000 - NUMBERING_RESERVATION_TTL=300 ports: - - "3001:3000" + - '3001:3000' depends_on: - mariadb - cache @@ -619,7 +620,7 @@ services: - lcbp3 restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + test: ['CMD', 'curl', '-f', 'http://localhost:3000/health'] interval: 30s timeout: 10s retries: 3 @@ -633,7 +634,7 @@ services: - REDIS_HOST=cache - REDIS_PORT=6379 ports: - - "3002:3000" + - '3002:3000' depends_on: - mariadb - cache @@ -647,6 +648,7 @@ networks: ``` #### Health Check Endpoint + ```typescript // health/numbering.health.ts import { Injectable } from '@nestjs/common'; @@ -658,17 +660,13 @@ import { DataSource } from 'typeorm'; export class NumberingHealthIndicator extends HealthIndicator { constructor( private redis: Redis, - private dataSource: DataSource, + private dataSource: DataSource ) { super(); } async isHealthy(key: string): Promise { - const checks = await Promise.all([ - this.checkRedis(), - this.checkDatabase(), - this.checkSequenceIntegrity(), - ]); + const checks = await Promise.all([this.checkRedis(), this.checkDatabase(), this.checkSequenceIntegrity()]); const isHealthy = checks.every((check) => check.status === 'up'); @@ -737,7 +735,7 @@ alerting: - alertmanager:9093 rule_files: - - "/etc/prometheus/alerts/numbering.yml" + - '/etc/prometheus/alerts/numbering.yml' scrape_configs: - job_name: 'backend' @@ -815,6 +813,7 @@ receivers: ### 4.3 Grafana Dashboards #### Import Dashboard JSON + ```bash # Download dashboard template curl -o numbering-dashboard.json \ @@ -827,6 +826,7 @@ curl -X POST http://admin:admin@localhost:3000/api/dashboards/db \ ``` #### Key Panels to Monitor + 1. **Numbers Generated per Minute** - Rate of number creation 2. **Sequence Utilization** - Current usage vs max (alert >90%) 3. **Lock Wait Time (p95)** - Performance indicator @@ -841,6 +841,7 @@ curl -X POST http://admin:admin@localhost:3000/api/dashboards/db \ ### 5.1 Database Backup Strategy #### Automated Backup Script + ```bash #!/bin/bash # scripts/backup-numbering-db.sh @@ -875,6 +876,7 @@ echo "✅ Backup complete: numbering_$DATE.sql.gz" ``` #### Cron Schedule + ```cron # Run backup daily at 2 AM 0 2 * * * /opt/lcbp3/scripts/backup-numbering-db.sh >> /var/log/numbering-backup.log 2>&1 @@ -886,6 +888,7 @@ echo "✅ Backup complete: numbering_$DATE.sql.gz" ### 5.2 Redis Backup #### Enable RDB Persistence + ```conf # redis.conf save 900 1 # Save if 1 key changed after 900 seconds @@ -902,6 +905,7 @@ appendfsync everysec ``` #### Backup Script + ```bash #!/bin/bash # scripts/backup-redis.sh @@ -941,6 +945,7 @@ echo "✅ Redis backup complete: redis_${DATE}.tar.gz" ### 5.3 Recovery Procedures #### Scenario 1: Restore from Database Backup + ```bash #!/bin/bash # scripts/restore-numbering-db.sh @@ -976,6 +981,7 @@ echo "🔄 Please verify sequence integrity" ``` #### Scenario 2: Redis Failure + ```bash # Check Redis status docker exec cache redis-cli ping @@ -997,6 +1003,7 @@ docker exec cache redis-cli ping ### 6.1 Sequence Adjustment #### Increase Max Value + ```sql -- Check current utilization SELECT @@ -1026,6 +1033,7 @@ INSERT INTO document_numbering_audit_logs ( ``` #### Reset Yearly Sequence + ```sql -- For document types with yearly reset -- Run on January 1st @@ -1091,6 +1099,7 @@ LINES TERMINATED BY '\n'; ### 6.3 Redis Maintenance #### Flush Expired Reservations + ```bash #!/bin/bash # scripts/cleanup-expired-reservations.sh @@ -1122,6 +1131,7 @@ echo "✅ Cleaned up $COUNT expired reservations" ### 7.1 Total System Failure #### Recovery Steps + ```bash #!/bin/bash # scripts/disaster-recovery.sh @@ -1184,7 +1194,9 @@ echo "⚠️ Please verify system functionality manually" **Alert**: `SequenceWarning` or `SequenceCritical` **Steps**: + 1. Check current utilization + ```sql SELECT document_type, current_value, max_value, ROUND((current_value * 100.0 / max_value), 2) as pct @@ -1199,6 +1211,7 @@ echo "⚠️ Please verify system functionality manually" - Days until exhaustion? 3. Take action + ```sql -- Option A: Increase max_value UPDATE document_numbering_configs @@ -1219,13 +1232,16 @@ echo "⚠️ Please verify system functionality manually" **Alert**: `HighLockWaitTime` **Steps**: + 1. Check Redis cluster health + ```bash docker exec lcbp3-redis-1 redis-cli cluster info docker exec lcbp3-redis-1 redis-cli cluster nodes ``` 2. Check database locks + ```sql SELECT * FROM information_schema.innodb_lock_waits; SELECT * FROM information_schema.innodb_trx @@ -1251,18 +1267,22 @@ echo "⚠️ Please verify system functionality manually" **Alert**: `RedisUnavailable` **Steps**: + 1. Verify Redis is down + ```bash docker exec cache redis-cli ping || echo "Redis DOWN" ``` 2. Check system falls back to DB-only mode + ```bash curl http://localhost:3001/health/numbering # Should show: fallback_mode: true ``` 3. Restart Redis container + ```bash docker restart cache sleep 10 @@ -1270,11 +1290,13 @@ echo "⚠️ Please verify system functionality manually" ``` 4. If restart fails, restore from backup + ```bash ./scripts/restore-redis.sh /backups/redis/latest.tar.gz ``` 5. Verify numbering system back to normal + ```bash curl http://localhost:3001/health/numbering # Should show: fallback_mode: false @@ -1292,6 +1314,7 @@ echo "⚠️ Please verify system functionality manually" ### 9.1 Slow Number Generation **Diagnosis**: + ```sql -- Check slow queries SELECT * FROM mysql.slow_log @@ -1306,6 +1329,7 @@ FOR UPDATE; ``` **Optimizations**: + ```sql -- Add missing indexes CREATE INDEX idx_sequence_lookup @@ -1373,8 +1397,8 @@ networks: - subnet: 172.20.0.0/16 driver_opts: com.docker.network.bridge.name: lcbp3-br - com.docker.network.bridge.enable_icc: "true" - com.docker.network.bridge.enable_ip_masquerade: "true" + com.docker.network.bridge.enable_icc: 'true' + com.docker.network.bridge.enable_ip_masquerade: 'true' ``` --- @@ -1383,7 +1407,7 @@ networks: ### 11.1 Audit Log Retention -```sql +````sql -- Export audit logs for compliance SELECT * FROM document_numbering @@ -1419,7 +1443,7 @@ chmod 755 /share/np-dms/pma/tmp # CREATE USER 'exporter'@'%' IDENTIFIED BY '' WITH MAX_USER_CONNECTIONS 3; # GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'exporter'@'%'; # FLUSH PRIVILEGES; -``` +```` ### Add Databases for NPM & Gitea @@ -1444,10 +1468,10 @@ x-restart: &restart_policy x-logging: &default_logging logging: - driver: "json-file" + driver: 'json-file' options: - max-size: "10m" - max-file: "5" + max-size: '10m' + max-file: '5' networks: lcbp3: @@ -1463,27 +1487,27 @@ services: deploy: resources: limits: - cpus: "2.0" + cpus: '2.0' memory: 4G reservations: - cpus: "0.5" + cpus: '0.5' memory: 1G environment: - MYSQL_ROOT_PASSWORD: "" - MYSQL_DATABASE: "lcbp3" - MYSQL_USER: "center" - MYSQL_PASSWORD: "" - TZ: "Asia/Bangkok" + MYSQL_ROOT_PASSWORD: '' + MYSQL_DATABASE: 'lcbp3' + MYSQL_USER: 'center' + MYSQL_PASSWORD: '' + TZ: 'Asia/Bangkok' ports: - - "3306:3306" + - '3306:3306' networks: - lcbp3 volumes: - - "/share/np-dms/mariadb/data:/var/lib/mysql" - - "/share/np-dms/mariadb/my.cnf:/etc/mysql/conf.d/my.cnf:ro" - - "/share/np-dms/mariadb/init:/docker-entrypoint-initdb.d:ro" + - '/share/np-dms/mariadb/data:/var/lib/mysql' + - '/share/np-dms/mariadb/my.cnf:/etc/mysql/conf.d/my.cnf:ro' + - '/share/np-dms/mariadb/init:/docker-entrypoint-initdb.d:ro' healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'] interval: 10s timeout: 5s retries: 3 @@ -1496,22 +1520,22 @@ services: deploy: resources: limits: - cpus: "0.25" + cpus: '0.25' memory: 256M environment: - TZ: "Asia/Bangkok" - PMA_HOST: "mariadb" - PMA_PORT: "3306" - PMA_ABSOLUTE_URI: "https://pma.np-dms.work/" - UPLOAD_LIMIT: "1G" - MEMORY_LIMIT: "512M" + TZ: 'Asia/Bangkok' + PMA_HOST: 'mariadb' + PMA_PORT: '3306' + PMA_ABSOLUTE_URI: 'https://pma.np-dms.work/' + UPLOAD_LIMIT: '1G' + MEMORY_LIMIT: '512M' ports: - - "89:80" + - '89:80' networks: - lcbp3 volumes: - - "/share/np-dms/pma/config.user.inc.php:/etc/phpmyadmin/config.user.inc.php:ro" - - "/share/np-dms/pma/tmp:/var/lib/phpmyadmin/tmp:rw" + - '/share/np-dms/pma/config.user.inc.php:/etc/phpmyadmin/config.user.inc.php:ro' + - '/share/np-dms/pma/tmp:/var/lib/phpmyadmin/tmp:rw' depends_on: mariadb: condition: service_healthy @@ -1546,10 +1570,10 @@ x-restart: &restart_policy x-logging: &default_logging logging: - driver: "json-file" + driver: 'json-file' options: - max-size: "10m" - max-file: "5" + max-size: '10m' + max-file: '5' networks: lcbp3: @@ -1563,21 +1587,21 @@ services: deploy: resources: limits: - cpus: "1.0" + cpus: '1.0' memory: 2G reservations: - cpus: "0.25" + cpus: '0.25' memory: 512M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' ports: - - "6379:6379" + - '6379:6379' networks: - lcbp3 volumes: - - "/share/np-dms/services/cache/data:/data" + - '/share/np-dms/services/cache/data:/data' healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 10s timeout: 5s retries: 5 @@ -1589,25 +1613,29 @@ services: deploy: resources: limits: - cpus: "2.0" + cpus: '2.0' memory: 4G reservations: - cpus: "0.5" + cpus: '0.5' memory: 2G environment: - TZ: "Asia/Bangkok" - discovery.type: "single-node" - xpack.security.enabled: "false" + TZ: 'Asia/Bangkok' + discovery.type: 'single-node' + xpack.security.enabled: 'false' # Heap locked at 1GB per ADR-005 - ES_JAVA_OPTS: "-Xms1g -Xmx1g" + ES_JAVA_OPTS: '-Xms1g -Xmx1g' ports: - - "9200:9200" + - '9200:9200' networks: - lcbp3 volumes: - - "/share/np-dms/services/search/data:/usr/share/elasticsearch/data" + - '/share/np-dms/services/search/data:/usr/share/elasticsearch/data' healthcheck: - test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"\\|\"status\":\"yellow\"'"] + test: + [ + 'CMD-SHELL', + "curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"\\|\"status\":\"yellow\"'", + ] interval: 30s timeout: 10s retries: 5 @@ -1622,15 +1650,15 @@ services: ### NPM Proxy Host Config Reference -| Domain | Forward Host | Port | Cache | Block Exploits | WebSocket | SSL | -| :-------------------- | :----------- | :--- | :---- | :------------- | :-------- | :--- | -| `backend.np-dms.work` | `backend` | 3000 | ❌ | ✅ | ❌ | ✅ | -| `lcbp3.np-dms.work` | `frontend` | 3000 | ✅ | ✅ | ✅ | ✅ | -| `git.np-dms.work` | `gitea` | 3000 | ✅ | ✅ | ✅ | ✅ | -| `n8n.np-dms.work` | `n8n` | 5678 | ✅ | ✅ | ✅ | ✅ | -| `chat.np-dms.work` | `rocketchat` | 3000 | ✅ | ✅ | ✅ | ✅ | -| `npm.np-dms.work` | `npm` | 81 | ❌ | ✅ | ✅ | ✅ | -| `pma.np-dms.work` | `pma` | 80 | ✅ | ✅ | ❌ | ✅ | +| Domain | Forward Host | Port | Cache | Block Exploits | WebSocket | SSL | +| :-------------------- | :----------- | :--- | :---- | :------------- | :-------- | :-- | +| `backend.np-dms.work` | `backend` | 3000 | ❌ | ✅ | ❌ | ✅ | +| `lcbp3.np-dms.work` | `frontend` | 3000 | ✅ | ✅ | ✅ | ✅ | +| `git.np-dms.work` | `gitea` | 3000 | ✅ | ✅ | ✅ | ✅ | +| `n8n.np-dms.work` | `n8n` | 5678 | ✅ | ✅ | ✅ | ✅ | +| `chat.np-dms.work` | `rocketchat` | 3000 | ✅ | ✅ | ✅ | ✅ | +| `npm.np-dms.work` | `npm` | 81 | ❌ | ✅ | ✅ | ✅ | +| `pma.np-dms.work` | `pma` | 80 | ✅ | ✅ | ❌ | ✅ | ```yaml # /share/np-dms/npm/docker-compose.yml @@ -1639,10 +1667,10 @@ x-restart: &restart_policy x-logging: &default_logging logging: - driver: "json-file" + driver: 'json-file' options: - max-size: "10m" - max-file: "5" + max-size: '10m' + max-file: '5' networks: lcbp3: @@ -1659,34 +1687,34 @@ services: deploy: resources: limits: - cpus: "1.0" + cpus: '1.0' memory: 512M ports: - - "80:80" - - "443:443" - - "81:81" + - '80:80' + - '443:443' + - '81:81' environment: - TZ: "Asia/Bangkok" - DB_MYSQL_HOST: "mariadb" + TZ: 'Asia/Bangkok' + DB_MYSQL_HOST: 'mariadb' DB_MYSQL_PORT: 3306 - DB_MYSQL_USER: "npm" - DB_MYSQL_PASSWORD: "npm" - DB_MYSQL_NAME: "npm" - DISABLE_IPV6: "true" + DB_MYSQL_USER: 'npm' + DB_MYSQL_PASSWORD: 'npm' + DB_MYSQL_NAME: 'npm' + DISABLE_IPV6: 'true' networks: - lcbp3 - giteanet volumes: - - "/share/np-dms/npm/data:/data" - - "/share/np-dms/npm/letsencrypt:/etc/letsencrypt" - - "/share/np-dms/npm/custom:/data/nginx/custom" + - '/share/np-dms/npm/data:/data' + - '/share/np-dms/npm/letsencrypt:/etc/letsencrypt' + - '/share/np-dms/npm/custom:/data/nginx/custom' landing: image: nginx:1.27-alpine container_name: landing restart: unless-stopped volumes: - - "/share/np-dms/npm/landing:/usr/share/nginx/html:ro" + - '/share/np-dms/npm/landing:/usr/share/nginx/html:ro' networks: - lcbp3 ``` @@ -1722,25 +1750,25 @@ services: container_name: gitea restart: always environment: - USER_UID: "1000" - USER_GID: "1000" + USER_UID: '1000' + USER_GID: '1000' TZ: Asia/Bangkok GITEA__server__ROOT_URL: https://git.np-dms.work/ GITEA__server__DOMAIN: git.np-dms.work GITEA__server__SSH_DOMAIN: git.np-dms.work - GITEA__server__START_SSH_SERVER: "true" - GITEA__server__SSH_PORT: "22" - GITEA__server__SSH_LISTEN_PORT: "22" - GITEA__server__LFS_START_SERVER: "true" - GITEA__server__HTTP_PORT: "3000" - GITEA__server__TRUSTED_PROXIES: "127.0.0.1/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" + GITEA__server__START_SSH_SERVER: 'true' + GITEA__server__SSH_PORT: '22' + GITEA__server__SSH_LISTEN_PORT: '22' + GITEA__server__LFS_START_SERVER: 'true' + GITEA__server__HTTP_PORT: '3000' + GITEA__server__TRUSTED_PROXIES: '127.0.0.1/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16' GITEA__database__DB_TYPE: mysql GITEA__database__HOST: mariadb:3306 - GITEA__database__NAME: "gitea" - GITEA__database__USER: "gitea" - GITEA__database__PASSWD: "" - GITEA__packages__ENABLED: "true" - GITEA__security__INSTALL_LOCK: "true" + GITEA__database__NAME: 'gitea' + GITEA__database__USER: 'gitea' + GITEA__database__PASSWD: '' + GITEA__packages__ENABLED: 'true' + GITEA__security__INSTALL_LOCK: 'true' volumes: - /share/np-dms/gitea/backup:/backup - /share/np-dms/gitea/etc:/etc/gitea @@ -1750,8 +1778,8 @@ services: - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro ports: - - "3003:3000" - - "2222:22" + - '3003:3000' + - '2222:22' networks: - lcbp3 - giteanet @@ -1775,10 +1803,10 @@ x-restart: &restart_policy x-logging: &default_logging logging: - driver: "json-file" + driver: 'json-file' options: - max-size: "10m" - max-file: "5" + max-size: '10m' + max-file: '5' networks: lcbp3: @@ -1792,39 +1820,39 @@ services: deploy: resources: limits: - cpus: "1.5" + cpus: '1.5' memory: 2G reservations: - cpus: "0.25" + cpus: '0.25' memory: 512M environment: - TZ: "Asia/Bangkok" - NODE_ENV: "production" - N8N_PUBLIC_URL: "https://n8n.np-dms.work/" - WEBHOOK_URL: "https://n8n.np-dms.work/" - N8N_HOST: "n8n.np-dms.work" + TZ: 'Asia/Bangkok' + NODE_ENV: 'production' + N8N_PUBLIC_URL: 'https://n8n.np-dms.work/' + WEBHOOK_URL: 'https://n8n.np-dms.work/' + N8N_HOST: 'n8n.np-dms.work' N8N_PORT: 5678 - N8N_PROTOCOL: "https" - N8N_PROXY_HOPS: "1" + N8N_PROTOCOL: 'https' + N8N_PROXY_HOPS: '1' N8N_DIAGNOSTICS_ENABLED: 'false' N8N_SECURE_COOKIE: 'true' # N8N_ENCRYPTION_KEY should be kept in .env (gitignored) N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: 'true' DB_TYPE: mysqldb - DB_MYSQLDB_DATABASE: "n8n" - DB_MYSQLDB_USER: "center" - DB_MYSQLDB_HOST: "mariadb" + DB_MYSQLDB_DATABASE: 'n8n' + DB_MYSQLDB_USER: 'center' + DB_MYSQLDB_HOST: 'mariadb' DB_MYSQLDB_PORT: 3306 ports: - - "5678:5678" + - '5678:5678' networks: lcbp3: {} volumes: - - "/share/np-dms/n8n:/home/node/.n8n" - - "/share/np-dms/n8n/cache:/home/node/.cache" - - "/share/np-dms/n8n/scripts:/scripts" + - '/share/np-dms/n8n:/home/node/.n8n' + - '/share/np-dms/n8n/cache:/home/node/.cache' + - '/share/np-dms/n8n/scripts:/scripts' healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5678/"] + test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:5678/'] interval: 15s timeout: 5s retries: 30 @@ -1839,4 +1867,3 @@ services: This stack contains `backend` (NestJS) and `frontend` (Next.js). Refer to [04-04-deployment-guide.md](./04-04-deployment-guide.md) for full deployment steps and CI/CD pipeline details. - diff --git a/specs/04-Infrastructure-OPS/04-02-backup-recovery.md b/specs/04-Infrastructure-OPS/04-02-backup-recovery.md index d87d178..91b8da2 100644 --- a/specs/04-Infrastructure-OPS/04-02-backup-recovery.md +++ b/specs/04-Infrastructure-OPS/04-02-backup-recovery.md @@ -1,4 +1,5 @@ # 04.2 Backup & Disaster Recovery + **Project:** LCBP3-DMS **Version:** 1.8.0 **Status:** Active @@ -391,7 +392,6 @@ WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR); **Last Review:** 2025-12-01 **Next Review:** 2026-03-01 - --- # Backup Strategy สำหรับ LCBP3-DMS @@ -642,7 +642,6 @@ restic -r /volume1/backup/restic-repo snapshots --latest 5 > 📝 **หมายเหตุ**: เอกสารนี้อ้างอิงจาก Architecture Document **v1.8.0** - --- # Disaster Recovery Plan สำหรับ LCBP3-DMS diff --git a/specs/04-Infrastructure-OPS/04-03-monitoring.md b/specs/04-Infrastructure-OPS/04-03-monitoring.md index f47b442..8fe5486 100644 --- a/specs/04-Infrastructure-OPS/04-03-monitoring.md +++ b/specs/04-Infrastructure-OPS/04-03-monitoring.md @@ -1,4 +1,5 @@ # 04.3 Monitoring & Alerting + **Project:** LCBP3-DMS **Version:** 1.8.0 **Status:** Active @@ -78,12 +79,7 @@ This document describes monitoring setup, health checks, and alerting rules for ```typescript // File: backend/src/health/health.controller.ts import { Controller, Get } from '@nestjs/common'; -import { - HealthCheck, - HealthCheckService, - TypeOrmHealthIndicator, - DiskHealthIndicator, -} from '@nestjs/terminus'; +import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator, DiskHealthIndicator } from '@nestjs/terminus'; @Controller('health') export class HealthController { @@ -208,12 +204,7 @@ done ```typescript // File: backend/src/common/interceptors/performance.interceptor.ts -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { logger } from 'src/config/logger.config'; @@ -460,7 +451,6 @@ ab -n 1000 -c 10 \ **Last Review:** 2025-12-01 **Next Review:** 2026-03-01 - --- # การติดตั้ง Monitoring Stack บน ASUSTOR @@ -472,15 +462,15 @@ ab -n 1000 -c 10 \ Stack สำหรับ Monitoring ประกอบด้วย: -| Service | Port | Purpose | Host | -| :---------------- | :--------------------------- | :-------------------------------- | :------ | +| Service | Port | Purpose | Host | +| :---------------- | :--------------------------- | :--------------------------------- | :------ | | **Prometheus** | 9090 | เก็บ Metrics และ Time-series data | ASUSTOR | -| **Grafana** | 3000 | Dashboard สำหรับแสดงผล Metrics | ASUSTOR | +| **Grafana** | 3000 | Dashboard สำหรับแสดงผล Metrics | ASUSTOR | | **Node Exporter** | 9100 | เก็บ Metrics ของ Host system | Both | | **cAdvisor** | 8080 (ASUSTOR) / 8088 (QNAP) | เก็บ Metrics ของ Docker containers | Both | -| **Uptime Kuma** | 3001 | Service Availability Monitoring | ASUSTOR | -| **Loki** | 3100 | Log aggregation | ASUSTOR | -| **Promtail** | - | Log shipper (Sender) | ASUSTOR | +| **Uptime Kuma** | 3001 | Service Availability Monitoring | ASUSTOR | +| **Loki** | 3100 | Log aggregation | ASUSTOR | +| **Promtail** | - | Log shipper (Sender) | ASUSTOR | --- @@ -613,10 +603,10 @@ x-restart: &restart_policy x-logging: &default_logging logging: - driver: "json-file" + driver: 'json-file' options: - max-size: "10m" - max-file: "5" + max-size: '10m' + max-file: '5' networks: lcbp3: @@ -635,27 +625,27 @@ services: deploy: resources: limits: - cpus: "1.0" + cpus: '1.0' memory: 1G reservations: - cpus: "0.25" + cpus: '0.25' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--storage.tsdb.retention.time=30d' - '--web.enable-lifecycle' ports: - - "9090:9090" + - '9090:9090' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/prometheus/config:/etc/prometheus:ro" - - "/volume1/np-dms/monitoring/prometheus/data:/prometheus" + - '/volume1/np-dms/monitoring/prometheus/config:/etc/prometheus:ro' + - '/volume1/np-dms/monitoring/prometheus/data:/prometheus' healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/healthy"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:9090/-/healthy'] interval: 30s timeout: 10s retries: 3 @@ -672,27 +662,27 @@ services: deploy: resources: limits: - cpus: "1.0" + cpus: '1.0' memory: 512M reservations: - cpus: "0.25" + cpus: '0.25' memory: 128M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' GF_SECURITY_ADMIN_USER: admin - GF_SECURITY_ADMIN_PASSWORD: "Center#2025" - GF_SERVER_ROOT_URL: "https://grafana.np-dms.work" + GF_SECURITY_ADMIN_PASSWORD: 'Center#2025' + GF_SERVER_ROOT_URL: 'https://grafana.np-dms.work' GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-piechart-panel ports: - - "3000:3000" + - '3000:3000' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/grafana/data:/var/lib/grafana" + - '/volume1/np-dms/monitoring/grafana/data:/var/lib/grafana' depends_on: - prometheus healthcheck: - test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/api/health || exit 1"] + test: ['CMD-SHELL', 'wget --spider -q http://localhost:3000/api/health || exit 1'] interval: 30s timeout: 10s retries: 3 @@ -707,18 +697,18 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' ports: - - "3001:3001" + - '3001:3001' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/uptime-kuma/data:/app/data" + - '/volume1/np-dms/monitoring/uptime-kuma/data:/app/data' healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3001/api/entry-page || exit 1"] + test: ['CMD-SHELL', 'curl -f http://localhost:3001/api/entry-page || exit 1'] interval: 30s timeout: 10s retries: 3 @@ -733,16 +723,16 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 128M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: - '--path.procfs=/host/proc' - '--path.sysfs=/host/sys' - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' ports: - - "9100:9100" + - '9100:9100' networks: - lcbp3 volumes: @@ -750,7 +740,7 @@ services: - /sys:/host/sys:ro - /:/rootfs:ro healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:9100/metrics"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:9100/metrics'] interval: 30s timeout: 10s retries: 3 @@ -768,12 +758,12 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' ports: - - "8088:8088" + - '8088:8088' networks: - lcbp3 volumes: @@ -783,7 +773,7 @@ services: - /var/lib/docker/:/var/lib/docker:ro - /dev/disk/:/dev/disk:ro healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:8080/healthz'] interval: 30s timeout: 10s retries: 3 @@ -798,19 +788,19 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 512M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: -config.file=/etc/loki/local-config.yaml ports: - - "3100:3100" + - '3100:3100' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/loki/data:/loki" + - '/volume1/np-dms/monitoring/loki/data:/loki' healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:3100/ready"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3100/ready'] interval: 30s timeout: 10s retries: 3 @@ -822,21 +812,21 @@ services: <<: [*restart_policy, *default_logging] image: grafana/promtail:2.9.0 container_name: promtail - user: "0:0" + user: '0:0' deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: -config.file=/etc/promtail/promtail-config.yml networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/promtail/config:/etc/promtail:ro" - - "/var/run/docker.sock:/var/run/docker.sock:ro" - - "/var/lib/docker/containers:/var/lib/docker/containers:ro" + - '/volume1/np-dms/monitoring/promtail/config:/etc/promtail:ro' + - '/var/run/docker.sock:/var/run/docker.sock:ro' + - '/var/lib/docker/containers:/var/lib/docker/containers:ro' depends_on: - loki ``` @@ -867,7 +857,7 @@ services: - '--path.sysfs=/host/sys' - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' ports: - - "9100:9100" + - '9100:9100' networks: - lcbp3 volumes: @@ -881,7 +871,7 @@ services: restart: unless-stopped privileged: true ports: - - "8088:8080" + - '8088:8080' networks: - lcbp3 volumes: @@ -899,11 +889,11 @@ services: command: - '--config.my-cnf=/etc/mysql/my.cnf' ports: - - "9104:9104" + - '9104:9104' networks: - lcbp3 volumes: - - "/share/np-dms/monitoring/mysqld-exporter/.my.cnf:/etc/mysql/my.cnf:ro" + - '/share/np-dms/monitoring/mysqld-exporter/.my.cnf:/etc/mysql/my.cnf:ro' ``` --- @@ -1012,7 +1002,6 @@ scrape_configs: | 14204 | Elasticsearch | Elasticsearch view | | 13106 | MySQL/MariaDB Overview | Detailed MySQL/MariaDB metrics | - ### Import Dashboard via Grafana UI 1. Go to **Dashboards → Import** @@ -1026,13 +1015,13 @@ scrape_configs: ### 📋 Prerequisites Checklist -| # | ขั้นตอน | Status | -| :--- | :------------------------------------------------------------------------------------------------- | :----- | -| 1 | SSH เข้า ASUSTOR ได้ (`ssh admin@192.168.10.9`) | ✅ | -| 2 | Docker Network `lcbp3` สร้างแล้ว (ดูหัวข้อ [สร้าง Docker Network](#-สร้าง-docker-network-ทำครั้งแรกครั้งเดียว)) | ✅ | -| 3 | สร้าง Directories และกำหนดสิทธิ์แล้ว (ดูหัวข้อ [กำหนดสิทธิ](#กำหนดสิทธิ-บน-asustor)) | ✅ | -| 4 | สร้าง `prometheus.yml` แล้ว (ดูหัวข้อ [Prometheus Configuration](#prometheus-configuration)) | ✅ | -| 5 | สร้าง `promtail-config.yml` แล้ว (ดูหัวข้อ [Step 1.2](#step-12-สร้าง-promtail-configyml)) | ✅ | +| # | ขั้นตอน | Status | +| :-- | :-------------------------------------------------------------------------------------------------------------- | :----- | +| 1 | SSH เข้า ASUSTOR ได้ (`ssh admin@192.168.10.9`) | ✅ | +| 2 | Docker Network `lcbp3` สร้างแล้ว (ดูหัวข้อ [สร้าง Docker Network](#-สร้าง-docker-network-ทำครั้งแรกครั้งเดียว)) | ✅ | +| 3 | สร้าง Directories และกำหนดสิทธิ์แล้ว (ดูหัวข้อ [กำหนดสิทธิ](#กำหนดสิทธิ-บน-asustor)) | ✅ | +| 4 | สร้าง `prometheus.yml` แล้ว (ดูหัวข้อ [Prometheus Configuration](#prometheus-configuration)) | ✅ | +| 5 | สร้าง `promtail-config.yml` แล้ว (ดูหัวข้อ [Step 1.2](#step-12-สร้าง-promtail-configyml)) | ✅ | --- @@ -1093,7 +1082,7 @@ cat /volume1/np-dms/monitoring/prometheus/config/prometheus.yml ต้องสร้าง Config ให้ Promtail อ่าน logs จาก Docker containers และส่งไป Loki: -```bash +````bash # สร้างไฟล์ promtail-config.yml cat > /volume1/np-dms/monitoring/promtail/config/promtail-config.yml << 'EOF' server: @@ -1127,9 +1116,10 @@ EOF CREATE USER 'exporter'@'%' IDENTIFIED BY 'Center2025' WITH MAX_USER_CONNECTIONS 3; GRANT PROCESS, REPLICATION CLIENT, SELECT, SLAVE MONITOR ON *.* TO 'exporter'@'%'; FLUSH PRIVILEGES; -``` +```` ### 2. สร้างไฟล์คอนฟิก .my.cnf บน QNAP + เพื่อให้ `mysqld-exporter` อ่านรหัสผ่านที่มีตัวอักษรพิเศษได้ถูกต้อง: 1. **SSH เข้า QNAP** (หรือใช้ File Station สร้าง Folder): @@ -1143,11 +1133,11 @@ FLUSH PRIVILEGES; 3. **สร้างไฟล์ .my.cnf**: ```bash cat > /share/np-dms/monitoring/mysqld-exporter/.my.cnf << 'EOF' -[client] -user=exporter -password=Center2025 -host=mariadb -EOF + [client] + user=exporter + password=Center2025 + host=mariadb + EOF ``` 4. **กำหนดสิทธิ์ไฟล์** (เพื่อให้ Container อ่านไฟล์ได้): ```bash @@ -1155,8 +1145,10 @@ EOF ``` # ตรวจสอบ + cat /volume1/np-dms/monitoring/promtail/config/promtail-config.yml -``` + +```` --- @@ -1187,7 +1179,7 @@ docker compose up -d # ตรวจสอบ container status docker compose ps -``` +```` --- @@ -1200,15 +1192,15 @@ docker ps --filter "name=prometheus" --filter "name=grafana" \ --filter "name=cadvisor" --filter "name=loki" --filter "name=promtail" ``` -| Service | วิธีตรวจสอบ | Expected Result | -| :---------------- | :----------------------------------------------------------------- | :------------------------------------ | -| ✅ **Prometheus** | `curl http://192.168.10.9:9090/-/healthy` | `Prometheus Server is Healthy` | -| ✅ **Grafana** | เปิด `https://grafana.np-dms.work` (หรือ `http://192.168.10.9:3000`) | หน้า Login | -| ✅ **Uptime Kuma** | เปิด `https://uptime.np-dms.work` (หรือ `http://192.168.10.9:3001`) | หน้า Setup | -| ✅ **Node Exp.** | `curl http://192.168.10.9:9100/metrics \| head` | Metrics output | -| ✅ **cAdvisor** | `curl http://192.168.10.9:8080/healthz` | `ok` | -| ✅ **Loki** | `curl http://192.168.10.9:3100/ready` | `ready` | -| ✅ **Promtail** | เช็ค Logs: `docker logs promtail` | ไม่ควรมี Error + เห็น connection success | +| Service | วิธีตรวจสอบ | Expected Result | +| :----------------- | :------------------------------------------------------------------- | :--------------------------------------- | +| ✅ **Prometheus** | `curl http://192.168.10.9:9090/-/healthy` | `Prometheus Server is Healthy` | +| ✅ **Grafana** | เปิด `https://grafana.np-dms.work` (หรือ `http://192.168.10.9:3000`) | หน้า Login | +| ✅ **Uptime Kuma** | เปิด `https://uptime.np-dms.work` (หรือ `http://192.168.10.9:3001`) | หน้า Setup | +| ✅ **Node Exp.** | `curl http://192.168.10.9:9100/metrics \| head` | Metrics output | +| ✅ **cAdvisor** | `curl http://192.168.10.9:8080/healthz` | `ok` | +| ✅ **Loki** | `curl http://192.168.10.9:3100/ready` | `ready` | +| ✅ **Promtail** | เช็ค Logs: `docker logs promtail` | ไม่ควรมี Error + เห็น connection success | --- @@ -1262,30 +1254,33 @@ curl -s http://localhost:9090/api/v1/targets | grep -E '"qnap-(node|cadvisor)"' เพื่อการ Monitor ที่สมบูรณ์ แนะนำให้ Import Dashboards ต่อไปนี้: #### 6.1 Host Monitoring (Node Exporter) -* **Concept:** ดู resource ของเครื่อง Host (CPU, RAM, Disk, Network) -* **Dashboard ID:** `1860` (Node Exporter Full) -* **วิธี Import:** - 1. ไปที่ **Dashboards** → **New** → **Import** - 2. ช่อง **Import via grafana.com** ใส่เลข `1860` กด **Load** - 3. เลือก Data source: **Prometheus** - 4. กด **Import** + +- **Concept:** ดู resource ของเครื่อง Host (CPU, RAM, Disk, Network) +- **Dashboard ID:** `1860` (Node Exporter Full) +- **วิธี Import:** + 1. ไปที่ **Dashboards** → **New** → **Import** + 2. ช่อง **Import via grafana.com** ใส่เลข `1860` กด **Load** + 3. เลือก Data source: **Prometheus** + 4. กด **Import** #### 6.2 Container Monitoring (cAdvisor) -* **Concept:** ดู resource ของแต่ละ Container (เชื่อม Logs ด้วย) -* **Dashboard ID:** `14282` (Cadvisor exporter) -* **วิธี Import:** - 1. ใส่เลข `14282` กด **Load** - 2. เลือก Data source: **Prometheus** - 3. กด **Import** + +- **Concept:** ดู resource ของแต่ละ Container (เชื่อม Logs ด้วย) +- **Dashboard ID:** `14282` (Cadvisor exporter) +- **วิธี Import:** + 1. ใส่เลข `14282` กด **Load** + 2. เลือก Data source: **Prometheus** + 3. กด **Import** #### 6.3 Logs Monitoring (Loki Integration) + เพื่อให้ Dashboard ของ Container แสดง Logs จาก Loki ได้ด้วย: 1. เปิด Dashboard **Cadvisor exporter** ที่เพิ่ง Import มา 2. กดปุ่ม **Add visualization** (หรือ Edit dashboard) 3. เลือก Data source: **Loki** 4. ในช่อง Query ใส่: `{container="$name"}` - * *(Note: `$name` มาจาก Variable ของ Dashboard 14282)* + - _(Note: `$name` มาจาก Variable ของ Dashboard 14282)_ 5. ปรับ Visualization type เป็น **Logs** 6. ตั้งชื่อ Panel ว่า **"Container Logs"** 7. กด **Apply** และ **Save Dashboard** @@ -1316,8 +1311,6 @@ curl -s http://localhost:9090/api/v1/targets | grep -E '"qnap-(node|cadvisor)"' > 📝 **หมายเหตุ**: เอกสารนี้อ้างอิงจาก Architecture Document **v1.8.0** - Monitoring Stack deploy บน ASUSTOR AS5403T - - --- ## 📈 Document Numbering Specific Monitoring @@ -1389,9 +1382,9 @@ groups: severity: critical component: document-numbering annotations: - summary: "Redis is unavailable for document numbering" - description: "System is falling back to DB-only locking. Performance degraded by 30-50%." - runbook_url: "https://wiki.lcbp3/runbooks/redis-unavailable" + summary: 'Redis is unavailable for document numbering' + description: 'System is falling back to DB-only locking. Performance degraded by 30-50%.' + runbook_url: 'https://wiki.lcbp3/runbooks/redis-unavailable' # CRITICAL: High lock failure rate - alert: HighLockFailureRate @@ -1402,9 +1395,9 @@ groups: severity: critical component: document-numbering annotations: - summary: "Lock acquisition failure rate > 10%" - description: "Check Redis and database performance immediately" - runbook_url: "https://wiki.lcbp3/runbooks/high-lock-failure" + summary: 'Lock acquisition failure rate > 10%' + description: 'Check Redis and database performance immediately' + runbook_url: 'https://wiki.lcbp3/runbooks/high-lock-failure' # WARNING: Elevated lock failure rate - alert: ElevatedLockFailureRate @@ -1415,8 +1408,8 @@ groups: severity: warning component: document-numbering annotations: - summary: "Lock acquisition failure rate > 5%" - description: "Monitor closely. May escalate to critical soon." + summary: 'Lock acquisition failure rate > 5%' + description: 'Monitor closely. May escalate to critical soon.' # WARNING: Slow lock acquisition - alert: SlowLockAcquisition @@ -1429,8 +1422,8 @@ groups: severity: warning component: document-numbering annotations: - summary: "P95 lock acquisition time > 1 second" - description: "Lock acquisition is slower than expected. Check Redis latency." + summary: 'P95 lock acquisition time > 1 second' + description: 'Lock acquisition is slower than expected. Check Redis latency.' # WARNING: High retry count - alert: HighRetryCount @@ -1443,8 +1436,8 @@ groups: severity: warning component: document-numbering annotations: - summary: "Retry count > 100 per hour in project {{ $labels.project }}" - description: "High contention detected. Consider scaling." + summary: 'Retry count > 100 per hour in project {{ $labels.project }}' + description: 'High contention detected. Consider scaling.' # WARNING: Slow generation - alert: SlowDocumentNumberGeneration @@ -1457,8 +1450,8 @@ groups: severity: warning component: document-numbering annotations: - summary: "P95 generation time > 2 seconds" - description: "Document number generation is slower than SLA target" + summary: 'P95 generation time > 2 seconds' + description: 'Document number generation is slower than SLA target' ``` ### 3.3. AlertManager Configuration @@ -1553,4 +1546,3 @@ Dashboard panels ที่สำคัญ: 6. **DB Connection Pool Usage** (Gauge) - Query: `docnum_db_connection_pool_usage` - Alert threshold: > 80% - diff --git a/specs/04-Infrastructure-OPS/04-04-deployment-guide.md b/specs/04-Infrastructure-OPS/04-04-deployment-guide.md index 8c3ade2..4a01466 100644 --- a/specs/04-Infrastructure-OPS/04-04-deployment-guide.md +++ b/specs/04-Infrastructure-OPS/04-04-deployment-guide.md @@ -319,7 +319,7 @@ services: - discovery.type=single-node - xpack.security.enabled=true - ELASTIC_PASSWORD=${ELASTICSEARCH_PASSWORD} - - "ES_JAVA_OPTS=-Xms2g -Xmx2g" + - 'ES_JAVA_OPTS=-Xms2g -Xmx2g' volumes: - /volume1/lcbp3/volumes/elastic-data:/usr/share/elasticsearch/data networks: @@ -348,8 +348,8 @@ services: container_name: lcbp3-nginx restart: unless-stopped ports: - - "80:80" - - "443:443" + - '80:80' + - '443:443' volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./ssl:/etc/nginx/ssl:ro @@ -977,6 +977,7 @@ chmod -R 750 /share/np-dms/data/uploads 5. กด **Create** ตรวจสอบ Container Status: Applications → `lcbp3-app` + - ✅ `backend` → Running (healthy) - ✅ `frontend` → Running (healthy) diff --git a/specs/04-Infrastructure-OPS/04-05-maintenance-procedures.md b/specs/04-Infrastructure-OPS/04-05-maintenance-procedures.md index bc40c79..2756c56 100644 --- a/specs/04-Infrastructure-OPS/04-05-maintenance-procedures.md +++ b/specs/04-Infrastructure-OPS/04-05-maintenance-procedures.md @@ -382,10 +382,7 @@ docker exec lcbp3-redis redis-cli FLUSHDB async function bootstrap() { const app = await NestFactory.create(AppModule, { - logger: - process.env.NODE_ENV === 'production' - ? ['error', 'warn'] - : ['log', 'error', 'warn', 'debug'], + logger: process.env.NODE_ENV === 'production' ? ['error', 'warn'] : ['log', 'error', 'warn', 'debug'], }); // Enable compression @@ -465,18 +462,15 @@ echo "Security maintenance completed: $(date)" ### Unplanned Maintenance Procedures 1. **Assess Urgency** - - Can it wait for scheduled maintenance? - Is it causing active issues? 2. **Communicate Impact** - - Notify stakeholders immediately - Estimate downtime - Provide updates every 30 minutes 3. **Execute Carefully** - - Always backup first - Have rollback plan ready - Test in staging if possible diff --git a/specs/04-Infrastructure-OPS/04-06-security-operations.md b/specs/04-Infrastructure-OPS/04-06-security-operations.md index a91104e..9b69492 100644 --- a/specs/04-Infrastructure-OPS/04-06-security-operations.md +++ b/specs/04-Infrastructure-OPS/04-06-security-operations.md @@ -11,6 +11,7 @@ This document outlines security monitoring, access control management, vulnerability management, and security incident response for LCBP3-DMS. **Security Status as of 2026-03-19:** + - ✅ **0 known vulnerabilities** (Backend dependencies fully patched) - ✅ **52 vulnerabilities resolved** (27 high + 20 moderate + 5 low severity) - ✅ **Major security updates applied**: Elasticsearch 9.3.4, Nodemailer 8.0.3, UUID 13.0.0 @@ -319,7 +320,6 @@ FLUSH PRIVILEGES; ``` 3. **Notify stakeholders** - - Security officer - Management - Affected users (if applicable) @@ -624,4 +624,3 @@ Configure at: Gitea → Repository → Settings → Actions → Secrets | `PORT` | SSH Port (`22`) | | `USERNAME` | SSH user with Docker access | | `PASSWORD` | SSH password (prefer SSH Key) | - diff --git a/specs/04-Infrastructure-OPS/04-07-incident-response.md b/specs/04-Infrastructure-OPS/04-07-incident-response.md index 9731b26..051b600 100644 --- a/specs/04-Infrastructure-OPS/04-07-incident-response.md +++ b/specs/04-Infrastructure-OPS/04-07-incident-response.md @@ -401,22 +401,18 @@ Database connection pool was exhausted due to slow queries not releasing connect ### PIR Meeting Agenda 1. **Timeline Review** (10 min) - - What happened and when? - What was the impact? 2. **Root Cause Analysis** (15 min) - - Why did it happen? - What were the contributing factors? 3. **What Went Well** (10 min) - - What did we do right? - What helped us resolve quickly? 4. **What Went Wrong** (15 min) - - What could we have done better? - What slowed us down? diff --git a/specs/04-Infrastructure-OPS/04-08-release-management-policy.md b/specs/04-Infrastructure-OPS/04-08-release-management-policy.md index 1e3e727..7d8a85b 100644 --- a/specs/04-Infrastructure-OPS/04-08-release-management-policy.md +++ b/specs/04-Infrastructure-OPS/04-08-release-management-policy.md @@ -1,16 +1,19 @@ # 🚀 Release Management Policy — LCBP3-DMS v1.8.0 --- + title: 'Release Management Policy, Versioning Strategy, and Deployment Gates' version: 1.0.0 status: DRAFT owner: Nattanin Peancharoen (System Architect / Release Manager) last_updated: 2026-03-11 related: - - specs/04-Infrastructure-OPS/04-04-deployment-guide.md ← Blue-Green Deployment Detail - - specs/04-Infrastructure-OPS/04-07-incident-response.md - - specs/06-Decision-Records/ADR-015-deployment.md - - specs/00-Overview/00-04-stakeholder-signoff-and-risk.md + +- specs/04-Infrastructure-OPS/04-04-deployment-guide.md ← Blue-Green Deployment Detail +- specs/04-Infrastructure-OPS/04-07-incident-response.md +- specs/06-Decision-Records/ADR-015-deployment.md +- specs/00-Overview/00-04-stakeholder-signoff-and-risk.md + --- > [!IMPORTANT] @@ -30,11 +33,11 @@ v1.8.1 └────── MAJOR: Breaking Changes, Architectural Shift (กำหนดโดย PO) ``` -| Type | ตัวอย่าง | เมื่อไหร่ | -|------|---------|---------| +| Type | ตัวอย่าง | เมื่อไหร่ | +| --------- | --------------- | ------------------------------------------ | | **MAJOR** | v1.0.0 → v2.0.0 | Breaking Change, Major Architecture Change | -| **MINOR** | v1.8.0 → v1.9.0 | New Feature หลังจาก Sprint สำเร็จ | -| **PATCH** | v1.8.0 → v1.8.1 | Bug Fix, Security Patch | +| **MINOR** | v1.8.0 → v1.9.0 | New Feature หลังจาก Sprint สำเร็จ | +| **PATCH** | v1.8.0 → v1.8.1 | Bug Fix, Security Patch | ### Branch Strategy (Git Flow) @@ -69,12 +72,12 @@ lcbp3-backend:v1.8.0-rc.1 ← Release Candidate ## 2. 📋 Release Types & Cadence -| Release Type | Cadence | Who Approves | Notes | -|-------------|---------|-------------|-------| -| **Sprint Release** (Minor) | ทุก 2 สัปดาห์ | PO + Lead Dev | ตามแผน Sprint | -| **Hotfix** (Patch) | ตามเหตุการณ์ | Lead Dev (P0/P1) → PO Notify | ไม่รอ Sprint | -| **Emergency Hotfix** | ทันที (P0) | Lead Dev → แจ้ง PO พร้อมกัน | Security, System Down | -| **Major Release** | กำหนดโดย PO | PO + กทท. Sign-off | Phase Change | +| Release Type | Cadence | Who Approves | Notes | +| -------------------------- | ------------- | ---------------------------- | --------------------- | +| **Sprint Release** (Minor) | ทุก 2 สัปดาห์ | PO + Lead Dev | ตามแผน Sprint | +| **Hotfix** (Patch) | ตามเหตุการณ์ | Lead Dev (P0/P1) → PO Notify | ไม่รอ Sprint | +| **Emergency Hotfix** | ทันที (P0) | Lead Dev → แจ้ง PO พร้อมกัน | Security, System Down | +| **Major Release** | กำหนดโดย PO | PO + กทท. Sign-off | Phase Change | ### Sprint Release Calendar (ตัวอย่าง) @@ -89,6 +92,7 @@ Sprint 2: 15–28 มี.ค. 2569 → Release v1.10.0 (11 เม.ย.) ## 3. 🚦 Release Gate Process ### Gate 1: Code Complete (วันสุดท้ายของ Sprint) + ``` ✅ Feature Freeze — ไม่รับ Feature ใหม่เข้า Release Branch ✅ All PRs Merged to release/vX.Y.Z @@ -97,15 +101,15 @@ Sprint 2: 15–28 มี.ค. 2569 → Release v1.10.0 (11 เม.ย.) ### Gate 2: Quality Gate (T-3 วันก่อน Release) -| Checkpoint | Tool | Threshold | -|-----------|------|----------| -| **TypeScript Compile** | `tsc --noEmit` | 0 Errors | -| **Unit Tests Pass** | Jest | ≥ 80% Pass Rate | -| **E2E Tests (Core Flows)** | Playwright/Cypress | 100% Core Flows ผ่าน | -| **Security Scan** | `npm audit` | 0 Critical/High Vulnerabilities | -| **Lint** | ESLint | 0 Errors (Warnings ยอมรับได้) | -| **Build Success** | Docker Build | Exit 0 | -| **Image Size** | Docker inspect | < 2GB (Backend), < 1.5GB (Frontend) | +| Checkpoint | Tool | Threshold | +| -------------------------- | ------------------ | ----------------------------------- | +| **TypeScript Compile** | `tsc --noEmit` | 0 Errors | +| **Unit Tests Pass** | Jest | ≥ 80% Pass Rate | +| **E2E Tests (Core Flows)** | Playwright/Cypress | 100% Core Flows ผ่าน | +| **Security Scan** | `npm audit` | 0 Critical/High Vulnerabilities | +| **Lint** | ESLint | 0 Errors (Warnings ยอมรับได้) | +| **Build Success** | Docker Build | Exit 0 | +| **Image Size** | Docker inspect | < 2GB (Backend), < 1.5GB (Frontend) | **Owner:** Lead Dev **Tool:** Gitea CI/CD Pipeline (ADR-015) @@ -114,13 +118,13 @@ Sprint 2: 15–28 มี.ค. 2569 → Release v1.10.0 (11 เม.ย.) ### Gate 3: Staging Validation (T-2 วันก่อน Release) -| Checkpoint | ผ่านเมื่อ | Owner | -|-----------|---------|-------| -| Deploy to Staging Environment | สำเร็จ, ไม่มี Error | DevOps | -| Health Check `/health` → 200 | ✅ | Automated | -| Smoke Test (Manual): Login → Create Correspondence → Submit | ผ่าน | Dev หรือ QA | -| Migration Script (ถ้ามี Schema Change) | รันสำเร็จบน Staging Schema | DBA / Dev | -| Rollback Test: Deploy → Rollback → Verify | ระบบ Rollback ได้ใน < 5 นาที | DevOps | +| Checkpoint | ผ่านเมื่อ | Owner | +| ----------------------------------------------------------- | ---------------------------- | ----------- | +| Deploy to Staging Environment | สำเร็จ, ไม่มี Error | DevOps | +| Health Check `/health` → 200 | ✅ | Automated | +| Smoke Test (Manual): Login → Create Correspondence → Submit | ผ่าน | Dev หรือ QA | +| Migration Script (ถ้ามี Schema Change) | รันสำเร็จบน Staging Schema | DBA / Dev | +| Rollback Test: Deploy → Rollback → Verify | ระบบ Rollback ได้ใน < 5 นาที | DevOps | **Owner:** Nattanin P. @@ -164,12 +168,12 @@ PO Sign-off: ✅ อนุมัติ Release ### เมื่อไหร่ต้อง Hotfix -| Priority | ตัวอย่าง | SLA Start Hotfix | Deploy Target | -|---------|---------|-----------------|--------------| -| **P0 — Critical** | ระบบล่ม, Data Corruption, Security Breach | ทันที (< 30 นาที) | < 4 ชั่วโมง | -| **P1 — High** | Feature หลักทำงานผิด, Login Fail | < 2 ชั่วโมง | < 24 ชั่วโมง | -| **P2 — Medium** | Feature รองทำงานผิด | ใน Sprint ถัดไป | Sprint Release | -| **P3 — Low** | UI Cosmetic, Minor UX | Backlog | Sprint Release | +| Priority | ตัวอย่าง | SLA Start Hotfix | Deploy Target | +| ----------------- | ----------------------------------------- | ----------------- | -------------- | +| **P0 — Critical** | ระบบล่ม, Data Corruption, Security Breach | ทันที (< 30 นาที) | < 4 ชั่วโมง | +| **P1 — High** | Feature หลักทำงานผิด, Login Fail | < 2 ชั่วโมง | < 24 ชั่วโมง | +| **P2 — Medium** | Feature รองทำงานผิด | ใน Sprint ถัดไป | Sprint Release | +| **P3 — Low** | UI Cosmetic, Minor UX | Backlog | Sprint Release | ### Hotfix Workflow @@ -211,22 +215,22 @@ cd /volume1/lcbp3/scripts ### เมื่อไหร่ต้อง Rollback -| Trigger | Threshold | Action | -|---------|----------|--------| -| Health Check Fail หลัง Deploy | 3 consecutive failures | Auto-rollback | -| Error Rate สูง | > 5% ใน 15 นาทีแรก | Manual Rollback (DevOps trigger) | -| P90 Response Time สูงมาก | > 2000ms ต่อเนื่อง 5 นาที | Manual Rollback | -| Critical Bug พบใน Production | P0 Bug | Manual Rollback ทันที | -| Migration Fail | Error Rate > 20% | Manual Rollback + Notify | +| Trigger | Threshold | Action | +| ----------------------------- | ------------------------- | -------------------------------- | +| Health Check Fail หลัง Deploy | 3 consecutive failures | Auto-rollback | +| Error Rate สูง | > 5% ใน 15 นาทีแรก | Manual Rollback (DevOps trigger) | +| P90 Response Time สูงมาก | > 2000ms ต่อเนื่อง 5 นาที | Manual Rollback | +| Critical Bug พบใน Production | P0 Bug | Manual Rollback ทันที | +| Migration Fail | Error Rate > 20% | Manual Rollback + Notify | ### Rollback SLA -| Scenario | Target Rollback Time | -|----------|---------------------| -| Blue-Green Switch (nginx reload) | < 30 วินาที | -| Full Container Restart | < 5 นาที | -| Database Rollback (SQL Revert) | < 30 นาที | -| Full System Restore (Backup) | < 4 ชั่วโมง (RTO) | +| Scenario | Target Rollback Time | +| -------------------------------- | -------------------- | +| Blue-Green Switch (nginx reload) | < 30 วินาที | +| Full Container Restart | < 5 นาที | +| Database Rollback (SQL Revert) | < 30 นาที | +| Full System Restore (Backup) | < 4 ชั่วโมง (RTO) | ### Rollback Decision Tree @@ -286,40 +290,48 @@ Security Check: npm audit (ถ้าเป็น Security Bug) ```markdown # Release Notes — LCBP3-DMS v[X.Y.Z] + **Date:** YYYY-MM-DD | **Type:** Sprint Release / Hotfix ## 🆕 New Features + - [Feature Name]: [Brief description] ## 🐛 Bug Fixes + - **[BUG-ID]** [Screen/Module]: [What was wrong → What's fixed] ## 🔒 Security Updates + - [CVE/Issue]: [Description] ## ⚠️ Breaking Changes + - [If any — ระบุชัดเจน] ## 📋 Schema Changes + - [Table]: [Column added/modified/removed] - **Action Required:** Admin ต้อง Apply SQL ใน `deltas/XX-description.sql` ## 🔧 Configuration Changes + - [Env Var]: [Change description] ## 📊 Performance Impact + - [Module]: [Expected improvement/change] ``` ### Communication Channels -| Release Type | Channel | ผู้รับ | Timing | -|-------------|---------|-------|--------| -| **Sprint Release** | LINE Group (Support) | Org Admin ทุกองค์กร | T-1 วัน (แจ้งล่วงหน้า) | -| **Sprint Release** | Email | ผู้บริหาร + PO | หลัง Deploy เสร็จ | -| **Hotfix (P1)** | LINE Group | Org Admin | พร้อมกับ Deploy | -| **Hotfix (P0)** | LINE Direct | กทท. IT + NAP On-call | ก่อน Deploy (แจ้งว่ากำลังแก้) | -| **Maintenance Window** | Email + LINE | ทุก User | T-24 ชั่วโมง | +| Release Type | Channel | ผู้รับ | Timing | +| ---------------------- | -------------------- | --------------------- | ----------------------------- | +| **Sprint Release** | LINE Group (Support) | Org Admin ทุกองค์กร | T-1 วัน (แจ้งล่วงหน้า) | +| **Sprint Release** | Email | ผู้บริหาร + PO | หลัง Deploy เสร็จ | +| **Hotfix (P1)** | LINE Group | Org Admin | พร้อมกับ Deploy | +| **Hotfix (P0)** | LINE Direct | กทท. IT + NAP On-call | ก่อน Deploy (แจ้งว่ากำลังแก้) | +| **Maintenance Window** | Email + LINE | ทุก User | T-24 ชั่วโมง | ### Maintenance Window Policy @@ -337,13 +349,13 @@ Security Check: npm audit (ถ้าเป็น Security Bug) ## 8. 📊 Release Metrics & Tracking -| Metric | Target | วิธีวัด | -|--------|--------|--------| -| **Deployment Frequency** | 1 ครั้ง/สองสัปดาห์ | Gitea Release History | -| **Lead Time for Change** | < 3 วัน (code → production) | Commit Date → Deploy Date | -| **Change Failure Rate** | < 5% (% Release ที่ต้อง Rollback) | Rollback Log | -| **Mean Time to Restore (MTTR)** | < 4 ชั่วโมง (P0) / < 8 ชั่วโมง (P1) | Incident Log | -| **Time to Rollback** | < 5 นาที (Blue-Green Switch) | Deploy Log | +| Metric | Target | วิธีวัด | +| ------------------------------- | ----------------------------------- | ------------------------- | +| **Deployment Frequency** | 1 ครั้ง/สองสัปดาห์ | Gitea Release History | +| **Lead Time for Change** | < 3 วัน (code → production) | Commit Date → Deploy Date | +| **Change Failure Rate** | < 5% (% Release ที่ต้อง Rollback) | Rollback Log | +| **Mean Time to Restore (MTTR)** | < 4 ชั่วโมง (P0) / < 8 ชั่วโมง (P1) | Incident Log | +| **Time to Rollback** | < 5 นาที (Blue-Green Switch) | Deploy Log | > **หมายเหตุ:** Metrics เหล่านี้คือ **DORA Metrics** (DevOps Research and Assessment) > ติดตามใน Monthly Engineering Review @@ -434,14 +446,14 @@ STAGING_URL=https://staging.lcbp3-dms.internal ### สิ่งที่ต้องสร้างทุก Release -| Artifact | Location | Owner | Retention | -|----------|---------|-------|----------| -| Release Notes | `specs/99-archives/releases/v{X.Y.Z}.md` | PO | ตลอดไป | -| Docker Images | Internal Registry (Gitea) | DevOps | ล่าสุด 5 Versions | -| DB Backup (Pre-deploy) | QNAP `/volume1/lcbp3/shared/backups/` | DevOps | 30 วัน | -| Delta SQL File | `specs/03-Data-and-Storage/deltas/` | Dev | ตลอดไป (Git) | -| CHANGELOG.md Update | Root of Repo | Dev | ตลอดไป | -| Deploy Log | `/volume1/lcbp3/shared/logs/deploy.log` | DevOps (Auto) | 90 วัน | +| Artifact | Location | Owner | Retention | +| ---------------------- | ---------------------------------------- | ------------- | ----------------- | +| Release Notes | `specs/99-archives/releases/v{X.Y.Z}.md` | PO | ตลอดไป | +| Docker Images | Internal Registry (Gitea) | DevOps | ล่าสุด 5 Versions | +| DB Backup (Pre-deploy) | QNAP `/volume1/lcbp3/shared/backups/` | DevOps | 30 วัน | +| Delta SQL File | `specs/03-Data-and-Storage/deltas/` | Dev | ตลอดไป (Git) | +| CHANGELOG.md Update | Root of Repo | Dev | ตลอดไป | +| Deploy Log | `/volume1/lcbp3/shared/logs/deploy.log` | DevOps (Auto) | 90 วัน | --- @@ -450,6 +462,7 @@ STAGING_URL=https://staging.lcbp3-dms.internal ### Sprint Release Checklist **T-3 วัน (Quality Gate)** + - [ ] All Unit Tests pass ≥ 80% coverage - [ ] TypeScript 0 Errors - [ ] ESLint 0 Errors @@ -459,14 +472,16 @@ STAGING_URL=https://staging.lcbp3-dms.internal - [ ] Delta SQL file ready (ถ้ามี Schema Change) **T-1 วัน (Staging + Approval)** + - [ ] Deploy to Staging สำเร็จ - [ ] Smoke Test on Staging ผ่าน - [ ] Schema Migration Test on Staging ผ่าน (ถ้ามี) - [ ] PO Review Complete -- [ ] PO Sign-off: "___ วันที่ ___" +- [ ] PO Sign-off: "**_ วันที่ _**" - [ ] Org Admin Notification ส่งแล้ว (LINE) **Release Day** + - [ ] DB Backup created + verified - [ ] Schema Delta Applied (ถ้ามี) — แจ้ง Admin ทำ Manual - [ ] `./deploy.sh` รัน (Blue-Green) diff --git a/specs/04-Infrastructure-OPS/README.md b/specs/04-Infrastructure-OPS/README.md index d7ecf4b..e1c9534 100644 --- a/specs/04-Infrastructure-OPS/README.md +++ b/specs/04-Infrastructure-OPS/README.md @@ -16,16 +16,16 @@ It consolidates what was previously split across multiple operations and specifi ## 📂 Document Index -| File | Purpose | Key Contents | -| ------------------------------------------------------------------------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| **[04-01-docker-compose.md](./04-01-docker-compose.md)** | Core Environment Setup | `.env` configs, Blue/Green Docker Compose, MariaDB & Redis optimization, **Appendix A: Live QNAP configs** (MariaDB, Redis/ES, NPM, Gitea, n8n) | -| **[04-02-backup-recovery.md](./04-02-backup-recovery.md)** | Disaster Recovery | RTO/RPO strategies, QNAP to ASUSTOR backup scripts, Restic/Mysqldump config | -| **[04-03-monitoring.md](./04-03-monitoring.md)** | Observability | Prometheus metrics, AlertManager rules, Grafana alerts | -| **[04-04-deployment-guide.md](./04-04-deployment-guide.md)** | Production Rollout | Blue-Green deployment scripts, **Appendix A: QNAP Container Station**, **Appendix B: Gitea Actions CI/CD**, **Appendix C: act_runner setup** | -| **[04-05-maintenance-procedures.md](./04-05-maintenance-procedures.md)** | Routine Care | Log rotation, dependency updates, scheduled DB optimizations | -| **[04-06-security-operations.md](./04-06-security-operations.md)** | Hardening & Audit | User access review, SSL renewals, vulnerability scanning, **Appendix A: SSH Setup**, **Appendix B: Secrets Management** | -| **[04-07-incident-response.md](./04-07-incident-response.md)** | Escalation | P0-P3 classifications, incident commander roles, Post-Incident Review | -| **[🚀 04-08-release-management-policy.md](./04-08-release-management-policy.md)** | Release Policy | SemVer, Git Flow, 5 Release Gates, Hotfix Process, Rollback Policy, CI/CD Pipeline | +| File | Purpose | Key Contents | +| --------------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| **[04-01-docker-compose.md](./04-01-docker-compose.md)** | Core Environment Setup | `.env` configs, Blue/Green Docker Compose, MariaDB & Redis optimization, **Appendix A: Live QNAP configs** (MariaDB, Redis/ES, NPM, Gitea, n8n) | +| **[04-02-backup-recovery.md](./04-02-backup-recovery.md)** | Disaster Recovery | RTO/RPO strategies, QNAP to ASUSTOR backup scripts, Restic/Mysqldump config | +| **[04-03-monitoring.md](./04-03-monitoring.md)** | Observability | Prometheus metrics, AlertManager rules, Grafana alerts | +| **[04-04-deployment-guide.md](./04-04-deployment-guide.md)** | Production Rollout | Blue-Green deployment scripts, **Appendix A: QNAP Container Station**, **Appendix B: Gitea Actions CI/CD**, **Appendix C: act_runner setup** | +| **[04-05-maintenance-procedures.md](./04-05-maintenance-procedures.md)** | Routine Care | Log rotation, dependency updates, scheduled DB optimizations | +| **[04-06-security-operations.md](./04-06-security-operations.md)** | Hardening & Audit | User access review, SSL renewals, vulnerability scanning, **Appendix A: SSH Setup**, **Appendix B: Secrets Management** | +| **[04-07-incident-response.md](./04-07-incident-response.md)** | Escalation | P0-P3 classifications, incident commander roles, Post-Incident Review | +| **[🚀 04-08-release-management-policy.md](./04-08-release-management-policy.md)** | Release Policy | SemVer, Git Flow, 5 Release Gates, Hotfix Process, Rollback Policy, CI/CD Pipeline | ### 🐳 Live Docker Compose Files (QNAP) diff --git a/specs/04-Infrastructure-OPS/grafana/dashboards/lcbp3-docker-monitoring.json b/specs/04-Infrastructure-OPS/grafana/dashboards/lcbp3-docker-monitoring.json index 7c45cac..482bc2c 100644 --- a/specs/04-Infrastructure-OPS/grafana/dashboards/lcbp3-docker-monitoring.json +++ b/specs/04-Infrastructure-OPS/grafana/dashboards/lcbp3-docker-monitoring.json @@ -666,9 +666,7 @@ "footer": { "countRows": false, "fields": "", - "reducer": [ - "sum" - ], + "reducer": ["sum"], "show": false }, "showHeader": true, @@ -729,11 +727,7 @@ ], "refresh": "10s", "schemaVersion": 38, - "tags": [ - "docker", - "monitoring", - "lcbp3" - ], + "tags": ["docker", "monitoring", "lcbp3"], "templating": { "list": [ { @@ -767,12 +761,8 @@ "allValue": ".+", "current": { "selected": true, - "text": [ - "All" - ], - "value": [ - "$__all" - ] + "text": ["All"], + "value": ["$__all"] }, "datasource": { "type": "prometheus", @@ -807,4 +797,4 @@ "uid": "lcbp3-docker-metrics-logs", "version": 5, "weekStart": "" -} \ No newline at end of file +} diff --git a/specs/05-Engineering-Guidelines/05-01-fullstack-js-guidelines.md b/specs/05-Engineering-Guidelines/05-01-fullstack-js-guidelines.md index b5dbbaa..e111d45 100644 --- a/specs/05-Engineering-Guidelines/05-01-fullstack-js-guidelines.md +++ b/specs/05-Engineering-Guidelines/05-01-fullstack-js-guidelines.md @@ -48,14 +48,14 @@ ### **2.4 ข้อตกลงในการตั้งชื่อ (Naming Conventions)** -| Entity (สิ่งที่ตั้งชื่อ) | Convention (รูปแบบ) | Example (ตัวอย่าง) | -| :-------------------- | :----------------- | :--------------------------------- | -| Classes | PascalCase | UserService | -| Property | snake_case | user_id | -| Variables & Functions | camelCase | getUserInfo | -| Files & Folders | kebab-case | user-service.ts | -| Environment Variables | UPPERCASE | DATABASE_URL | -| Booleans | Verb + Noun | isActive, canDelete, hasPermission | +| Entity (สิ่งที่ตั้งชื่อ) | Convention (รูปแบบ) | Example (ตัวอย่าง) | +| :----------------------- | :------------------ | :--------------------------------- | +| Classes | PascalCase | UserService | +| Property | snake_case | user_id | +| Variables & Functions | camelCase | getUserInfo | +| Files & Folders | kebab-case | user-service.ts | +| Environment Variables | UPPERCASE | DATABASE_URL | +| Booleans | Verb + Noun | isActive, canDelete, hasPermission | ใช้คำเต็ม — ไม่ใช้อักษรย่อ — ยกเว้นคำมาตรฐาน (เช่น API, URL, req, res, err, ctx) @@ -165,13 +165,13 @@ const testScenarios = { ### **3.1.1 NestJS 11 Patterns (Updated 2026-03-16)** -| Pattern | คำอธิบาย | -| :--- | :--- | +| Pattern | คำอธิบาย | +| :-------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **`import type` สำหรับ decorated signatures** | เมื่อ `isolatedModules` + `emitDecoratorMetadata` เปิดอยู่ ต้องใช้ `import type` สำหรับ interface ที่ใช้ใน decorated parameter (เช่น `@Req() req: RequestWithUser`) | -| **Shared `RequestWithUser` interface** | ใช้ `src/common/interfaces/request-with-user.interface.ts` แทนการประกาศ local interface ในแต่ละ controller — ห้ามใช้ `req: any` | -| **`@nestjs/mapped-types` ถูกลบออก** | DTO utility types (`PartialType`, `OmitType`, `IntersectionType`) ต้อง import จาก `@nestjs/swagger` เท่านั้น | -| **Express v5** | `@nestjs/platform-express` v11 ใช้ Express 5 — path parameter syntax เปลี่ยน (`:id` → `:id` ยังใช้ได้ แต่ wildcard `*` → `*name`) | -| **Swagger version** | Swagger doc version ต้องตรงกับ project version ปัจจุบัน (`1.8.1`) | +| **Shared `RequestWithUser` interface** | ใช้ `src/common/interfaces/request-with-user.interface.ts` แทนการประกาศ local interface ในแต่ละ controller — ห้ามใช้ `req: any` | +| **`@nestjs/mapped-types` ถูกลบออก** | DTO utility types (`PartialType`, `OmitType`, `IntersectionType`) ต้อง import จาก `@nestjs/swagger` เท่านั้น | +| **Express v5** | `@nestjs/platform-express` v11 ใช้ Express 5 — path parameter syntax เปลี่ยน (`:id` → `:id` ยังใช้ได้ แต่ wildcard `*` → `*name`) | +| **Swagger version** | Swagger doc version ต้องตรงกับ project version ปัจจุบัน (`1.8.1`) | ```typescript // ✅ ถูกต้อง — NestJS 11 pattern @@ -428,34 +428,34 @@ Unified Workflow Engine (Core Architecture) ### **3.13 เเทคโนโลยีที่ใช้ (Technology Stack)** -| ส่วน | Library/Tool | หมายเหตุ | -| ----------------------- | ---------------------------------------------------- | -------------------------------------- | -| **Framework** | `@nestjs/core`, `@nestjs/common` | Core Framework | -| **Language** | `TypeScript` | ใช้ TypeScript ทั้งระบบ | -| **Database** | `MariaDB 11.8` | ฐานข้อมูลหลัก | -| **ORM** | `@nestjs/typeorm`, `typeorm` | 🗃️จัดการการเชื่อมต่อและ Query ฐานข้อมูล | -| **Validation** | `class-validator`, `class-transformer` | 📦ตรวจสอบและแปลงข้อมูลใน DTO | -| **Auth** | `@nestjs/jwt`, `@nestjs/passport`, `passport-jwt` | 🔐การยืนยันตัวตนด้วย JWT | -| **Authorization** | `casl` | 🔐จัดการสิทธิ์แบบ RBAC | -| **File Upload** | `multer` | 📁จัดการการอัปโหลดไฟล์ | -| **Search** | `@nestjs/elasticsearch` | 🔍สำหรับการค้นหาขั้นสูง | -| **Notification** | `nodemailer` | 📬ส่งอีเมลแจ้งเตือน | +| ส่วน | Library/Tool | หมายเหตุ | +| ----------------------- | ---------------------------------------------------- | -------------------------------------------- | +| **Framework** | `@nestjs/core`, `@nestjs/common` | Core Framework | +| **Language** | `TypeScript` | ใช้ TypeScript ทั้งระบบ | +| **Database** | `MariaDB 11.8` | ฐานข้อมูลหลัก | +| **ORM** | `@nestjs/typeorm`, `typeorm` | 🗃️จัดการการเชื่อมต่อและ Query ฐานข้อมูล | +| **Validation** | `class-validator`, `class-transformer` | 📦ตรวจสอบและแปลงข้อมูลใน DTO | +| **Auth** | `@nestjs/jwt`, `@nestjs/passport`, `passport-jwt` | 🔐การยืนยันตัวตนด้วย JWT | +| **Authorization** | `casl` | 🔐จัดการสิทธิ์แบบ RBAC | +| **File Upload** | `multer` | 📁จัดการการอัปโหลดไฟล์ | +| **Search** | `@nestjs/elasticsearch` | 🔍สำหรับการค้นหาขั้นสูง | +| **Notification** | `nodemailer` | 📬ส่งอีเมลแจ้งเตือน | | **Scheduling** | `@nestjs/schedule` | 📬สำหรับ Cron Jobs (เช่น แจ้งเตือน Deadline) | -| **Logging** | `winston` | 📊บันทึก Log ที่มีประสิทธิภาพ | -| **Testing** | `@nestjs/testing`, `jest`, `supertest` | 🧪ทดสอบ Unit, Integration และ E2E | -| **Documentation** | `@nestjs/swagger` | 🌐สร้าง API Documentation อัตโนมัติ | -| **Security** | `helmet`, `rate-limiter-flexible` | 🛡️เพิ่มความปลอดภัยให้ API | -| **Resilience** | `@nestjs/circuit-breaker` | 🔄 Circuit breaker pattern | -| **Caching** | `@nestjs/cache-manager`, `cache-manager-redis-store` | 💾 Distributed caching | -| **Security** | `helmet`, `csurf`, `rate-limiter-flexible` | 🛡️ Security enhancements | -| **Validation** | `class-validator`, `class-transformer` | ✅ Input validation | -| **Monitoring** | `@nestjs/monitoring`, `winston` | 📊 Application monitoring | -| **File Processing** | `clamscan` | 🦠 Virus scanning | -| **Cryptography** | `bcrypt`, `crypto` | 🔐 Password hashing และ checksums | -| **JSON Validation** | `ajv`, `ajv-formats` | 🎯 JSON schema validation | -| **JSON Processing** | `jsonpath`, `json-schema-ref-parser` | 🔧 JSON manipulation | -| **Data Transformation** | `class-transformer` | 🔄 Object transformation | -| **Compression** | `compression` | 📦 JSON compression | +| **Logging** | `winston` | 📊บันทึก Log ที่มีประสิทธิภาพ | +| **Testing** | `@nestjs/testing`, `jest`, `supertest` | 🧪ทดสอบ Unit, Integration และ E2E | +| **Documentation** | `@nestjs/swagger` | 🌐สร้าง API Documentation อัตโนมัติ | +| **Security** | `helmet`, `rate-limiter-flexible` | 🛡️เพิ่มความปลอดภัยให้ API | +| **Resilience** | `@nestjs/circuit-breaker` | 🔄 Circuit breaker pattern | +| **Caching** | `@nestjs/cache-manager`, `cache-manager-redis-store` | 💾 Distributed caching | +| **Security** | `helmet`, `csurf`, `rate-limiter-flexible` | 🛡️ Security enhancements | +| **Validation** | `class-validator`, `class-transformer` | ✅ Input validation | +| **Monitoring** | `@nestjs/monitoring`, `winston` | 📊 Application monitoring | +| **File Processing** | `clamscan` | 🦠 Virus scanning | +| **Cryptography** | `bcrypt`, `crypto` | 🔐 Password hashing และ checksums | +| **JSON Validation** | `ajv`, `ajv-formats` | 🎯 JSON schema validation | +| **JSON Processing** | `jsonpath`, `json-schema-ref-parser` | 🔧 JSON manipulation | +| **Data Transformation** | `class-transformer` | 🔄 Object transformation | +| **Compression** | `compression` | 📦 JSON compression | ### **3.14 Security Testing:** @@ -511,13 +511,11 @@ Backend (NestJS) ควรเป็น **Stateless** (ไม่เก็บส 1. User สร้างเอกสาร → เลือก routing template 2. System สร้าง routing instances ตาม template 3. สำหรับแต่ละ routing step: - - กำหนด due date (จาก expected_days) - ส่ง notification ไปยังองค์กรผู้รับ - อัพเดทสถานะเป็น SENT 4. เมื่อองค์กรผู้รับดำเนินการ: - - อัพเดทสถานะเป็น ACTIONED/FORWARDED/REPLIED - บันทึก processed_by และ processed_at - ส่ง notification ไปยังขั้นตอนต่อไป (ถ้ามี) @@ -731,27 +729,23 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { สำหรับ Next.js App Router เราจะใช้ State Management แบบ Simplified โดยแบ่งเป็น 3 ระดับหลัก: - 4.10.1. **Server State (สถานะข้อมูลจากเซิร์ฟเวอร์)** - - **เครื่องมือ:** **TanStack Query (React Query)** - **ใช้เมื่อ:** จัดการข้อมูลที่ดึงมาจาก NestJS API ทั้งหมด - **ครอบคลุม:** รายการ correspondences, rfas, drawings, users, permissions - **ประโยชน์:** จัดการ Caching, Re-fetching, Background Sync อัตโนมัติ - 4.10.2. **Form State (สถานะของฟอร์ม):** - - **เครื่องมือ:** **React Hook Form** + **Zod** (สำหรับ validation) - **ใช้เมื่อ:** จัดการฟอร์มที่ซับซ้อนทั้งหมด - **ครอบคลุม:** ฟอร์มสร้าง/แก้ไข RFA, Correspondence, Circulation - **รวมฟีเจอร์:** Auto-save drafts ลง LocalStorage - 4.10.3. **UI State (สถานะ UI ชั่วคราว):** - - **เครื่องมือ:** **useState**, **useReducer** (ใน Component) หรือ **Zustand** (สำหรับ Global Client State เช่น Preferences, Auth) - **ใช้เมื่อ:** จัดการสถานะเฉพาะ Component หรือข้อมูลที่แชร์ทั้งแอปโดยไม่พึ่งพาเซิร์ฟเวอร์ - **ครอบคลุม:** Modal เปิด/ปิด, Dropdown state, Loading states, Themes, Sidebar - **ยกเลิกการใช้:** - - ❌ Context API สำหรับ Server State (ใช้ React Query แทน) - **ตัวอย่าง Implementation:** @@ -868,15 +862,15 @@ updateRFA(@Param('id') id: string) { ## 🔗 **7. แนวทางการบูรณาการ Full Stack (Full Stack Integration Guidelines)** -| Aspect (แง่มุม) | Backend (NestJS) | Frontend (NextJS) | UI Layer (Tailwind/Shadcn) | -| :----------------------- | :------------------------- | :---------------------------- | :------------------------------------- | -| API | REST / GraphQL Controllers | API hooks ผ่าน fetch/axios/SWR | Components ที่รับข้อมูล | -| Validation (การตรวจสอบ) | class-validator DTOs | zod / react-hook-form | สถานะของฟอร์ม/input ใน Shadcn | -| Auth (การยืนยันตัวตน) | Guards, JWT | NextAuth / cookies | สถานะ UI ของ Auth (loading, signed in) | -| Errors (ข้อผิดพลาด) | Global filters | Toasts / modals | Alerts / ข้อความ feedback | -| Testing (การทดสอบ) | Jest (unit/e2e) | Vitest / Playwright | Visual regression | -| Styles (สไตล์) | Scoped modules (ถ้าจำเป็น) | Tailwind / Shadcn | Tailwind utilities | -| Accessibility (การเข้าถึง) | Guards + filters | ARIA attributes | Semantic HTML | +| Aspect (แง่มุม) | Backend (NestJS) | Frontend (NextJS) | UI Layer (Tailwind/Shadcn) | +| :------------------------- | :------------------------- | :----------------------------- | :------------------------------------- | +| API | REST / GraphQL Controllers | API hooks ผ่าน fetch/axios/SWR | Components ที่รับข้อมูล | +| Validation (การตรวจสอบ) | class-validator DTOs | zod / react-hook-form | สถานะของฟอร์ม/input ใน Shadcn | +| Auth (การยืนยันตัวตน) | Guards, JWT | NextAuth / cookies | สถานะ UI ของ Auth (loading, signed in) | +| Errors (ข้อผิดพลาด) | Global filters | Toasts / modals | Alerts / ข้อความ feedback | +| Testing (การทดสอบ) | Jest (unit/e2e) | Vitest / Playwright | Visual regression | +| Styles (สไตล์) | Scoped modules (ถ้าจำเป็น) | Tailwind / Shadcn | Tailwind utilities | +| Accessibility (การเข้าถึง) | Guards + filters | ARIA attributes | Semantic HTML | ## 🗂️ **8. ข้อตกลงเฉพาะสำหรับ DMS (LCBP3-DMS)** @@ -886,17 +880,17 @@ updateRFA(@Param('id') id: string) { บันทึกการดำเนินการ CRUD และการจับคู่ทั้งหมดลงในตาราง audit_logs -| Field (ฟิลด์) | Type (จาก SQL) | Description (คำอธิบาย) | -| :----------- | :------------- | :----------------------------------------------- | -| audit_id | BIGINT | Primary Key | -| user_id | INT | ผู้ใช้ที่ดำเนินการ (FK -> users) | -| action | VARCHAR(100) | rfa.create, correspondence.update, login.success | -| entity_type | VARCHAR(50) | ชื่อตาราง/โมดูล เช่น 'rfa', 'correspondence' | -| entity_id | VARCHAR(50) | Primary ID ของระเบียนที่ได้รับผลกระทบ | -| details_json | JSON | ข้อมูลบริบท (เช่น ฟิลด์ที่มีการเปลี่ยนแปลง) | -| ip_address | VARCHAR(45) | IP address ของผู้ดำเนินการ | -| user_agent | VARCHAR(255) | User Agent ของผู้ดำเนินการ | -| created_at | TIMESTAMP | Timestamp (UTC) | +| Field (ฟิลด์) | Type (จาก SQL) | Description (คำอธิบาย) | +| :------------ | :------------- | :----------------------------------------------- | +| audit_id | BIGINT | Primary Key | +| user_id | INT | ผู้ใช้ที่ดำเนินการ (FK -> users) | +| action | VARCHAR(100) | rfa.create, correspondence.update, login.success | +| entity_type | VARCHAR(50) | ชื่อตาราง/โมดูล เช่น 'rfa', 'correspondence' | +| entity_id | VARCHAR(50) | Primary ID ของระเบียนที่ได้รับผลกระทบ | +| details_json | JSON | ข้อมูลบริบท (เช่น ฟิลด์ที่มีการเปลี่ยนแปลง) | +| ip_address | VARCHAR(45) | IP address ของผู้ดำเนินการ | +| user_agent | VARCHAR(255) | User Agent ของผู้ดำเนินการ | +| created_at | TIMESTAMP | Timestamp (UTC) | ### 📂**8.2 การจัดการไฟล์ (File Handling)** diff --git a/specs/05-Engineering-Guidelines/05-02-backend-guidelines.md b/specs/05-Engineering-Guidelines/05-02-backend-guidelines.md index 5fae48f..ba6b2c7 100644 --- a/specs/05-Engineering-Guidelines/05-02-backend-guidelines.md +++ b/specs/05-Engineering-Guidelines/05-02-backend-guidelines.md @@ -361,10 +361,7 @@ describe('DocumentNumberingService', () => { beforeEach(async () => { const module = await Test.createTestingModule({ - providers: [ - DocumentNumberingService, - { provide: RedisLock, useValue: mockRedisLock }, - ], + providers: [DocumentNumberingService, { provide: RedisLock, useValue: mockRedisLock }], }).compile(); service = module.get(DocumentNumberingService); @@ -421,10 +418,7 @@ describe('Correspondence API (e2e)', () => { // src/modules/monitoring/logger/winston.config.ts export const winstonConfig = { level: process.env.NODE_ENV === 'production' ? 'warn' : 'debug', - format: winston.format.combine( - winston.format.timestamp(), - winston.format.json() - ), + format: winston.format.combine(winston.format.timestamp(), winston.format.json()), transports: [ new winston.transports.Console(), new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), @@ -463,14 +457,14 @@ async approve(@Param('id') id: string, @CurrentUser() user: User) { Backend codebase has **zero** `any` types remaining. Key techniques used: -| Pattern | Solution | -|---------|----------| -| JWT `expiresIn` branded type | `import type { StringValue } from 'ms'`; cast `as StringValue` | -| CASL `detectSubjectType` callback | Type param as `object`, internal cast via `Record` | -| CASL `ability.can()` params | Export `Actions`/`Subjects` types from `ability.factory.ts`, cast explicitly | -| TypeORM nullable column clearing | Use `undefined` instead of `null as any` for optional (`?:`) properties | -| Test mock objects | Use `as unknown as InterfaceType` or `as Partial as Entity` | -| TypeScript legacy decorators | `target: any` is unavoidable — whitelisted per TS spec limitation | +| Pattern | Solution | +| --------------------------------- | ---------------------------------------------------------------------------- | +| JWT `expiresIn` branded type | `import type { StringValue } from 'ms'`; cast `as StringValue` | +| CASL `detectSubjectType` callback | Type param as `object`, internal cast via `Record` | +| CASL `ability.can()` params | Export `Actions`/`Subjects` types from `ability.factory.ts`, cast explicitly | +| TypeORM nullable column clearing | Use `undefined` instead of `null as any` for optional (`?:`) properties | +| Test mock objects | Use `as unknown as InterfaceType` or `as Partial as Entity` | +| TypeScript legacy decorators | `target: any` is unavoidable — whitelisted per TS spec limitation | > **Exceptions:** Only `target: any` in legacy TS decorators (`circuit-breaker.decorator.ts`, `retry.decorator.ts`) remains — this is a TypeScript language constraint, not a code quality issue. @@ -487,7 +481,7 @@ Backend codebase has **zero** `any` types remaining. Key techniques used: ## 🔄 Update History -| Version | Date | Changes | -| ------- | ---------- | ---------------------------------- | -| 1.5.0 | 2025-12-01 | Initial backend guidelines created | +| Version | Date | Changes | +| ------- | ---------- | ------------------------------------------------------------------- | +| 1.5.0 | 2025-12-01 | Initial backend guidelines created | | 1.8.1 | 2026-03-20 | Added `any` type elimination techniques, enforced 0 remaining `any` | diff --git a/specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md b/specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md index 8d63855..938d61c 100644 --- a/specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md +++ b/specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md @@ -144,9 +144,7 @@ export function ResponsiveTable({ data }: { data: Correspondence[] }) {
-
- เลขที่เอกสาร -
+
เลขที่เอกสาร
{item.doc_number}
เรื่อง
{item.title}
@@ -367,11 +365,7 @@ export default apiClient; ```typescript // lib/services/correspondence.service.ts import apiClient from '@/lib/api/client'; -import type { - Correspondence, - CreateCorrespondenceDto, - SearchCorrespondenceDto, -} from '@/types/dto/correspondence'; +import type { Correspondence, CreateCorrespondenceDto, SearchCorrespondenceDto } from '@/types/dto/correspondence'; export const correspondenceService = { async getAll(params: SearchCorrespondenceDto): Promise { @@ -389,10 +383,7 @@ export const correspondenceService = { return data; }, - async update( - id: string, - dto: Partial - ): Promise { + async update(id: string, dto: Partial): Promise { const { data } = await apiClient.put(`/correspondences/${id}`, dto); return data; }, @@ -441,24 +432,20 @@ export function DynamicForm({ schemaName, onSubmit }: DynamicFormProps) { return (
- {Object.entries(schema.schema_definition.properties).map( - ([key, prop]: [string, Record]) => ( - ( - - {prop.title || key} - - {renderFieldByType(prop.type, field)} - - - - )} - /> - ) - )} + {Object.entries(schema.schema_definition.properties).map(([key, prop]: [string, Record]) => ( + ( + + {prop.title || key} + {renderFieldByType(prop.type, field)} + + + )} + /> + ))} @@ -525,24 +512,17 @@ export function FileUploadZone({ className={` border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors - ${ - isDragActive - ? 'border-primary bg-primary/5' - : 'border-muted-foreground/25' - } + ${isDragActive ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'} hover:border-primary hover:bg-primary/5 `} >

- {isDragActive - ? 'วางไฟล์ที่นี่...' - : 'ลากไฟล์มาวางที่นี่ หรือคลิกเพื่อเลือกไฟล์'} + {isDragActive ? 'วางไฟล์ที่นี่...' : 'ลากไฟล์มาวางที่นี่ หรือคลิกเพื่อเลือกไฟล์'}

- รองรับ: {acceptedTypes.join(', ')} (สูงสุด {maxFiles} ไฟล์,{' '} - {maxSize / 1024 / 1024}MB/ไฟล์) + รองรับ: {acceptedTypes.join(', ')} (สูงสุด {maxFiles} ไฟล์, {maxSize / 1024 / 1024}MB/ไฟล์)

); @@ -583,9 +563,7 @@ describe('CorrespondenceForm', () => { fireEvent.click(submitButton); await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Test Correspondence' }) - ); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ title: 'Test Correspondence' })); }); }); }); diff --git a/specs/05-Engineering-Guidelines/05-04-testing-strategy.md b/specs/05-Engineering-Guidelines/05-04-testing-strategy.md index fbbcf94..5c5b3d6 100644 --- a/specs/05-Engineering-Guidelines/05-04-testing-strategy.md +++ b/specs/05-Engineering-Guidelines/05-04-testing-strategy.md @@ -52,11 +52,11 @@ related: ### Test Distribution -| Level | Coverage | Speed | Purpose | -| ----------- | -------- | ---------- | ---------------------------- | -| Unit | 60% | Fast (ms) | ทดสอบตรรกะแต่ละ Function | +| Level | Coverage | Speed | Purpose | +| ----------- | -------- | ---------- | ------------------------------- | +| Unit | 60% | Fast (ms) | ทดสอบตรรกะแต่ละ Function | | Integration | 30% | Medium (s) | ทดสอบการทำงานร่วมกันของ Modules | -| E2E | 10% | Slow (m) | ทดสอบ User Journey ทั้งหมด | +| E2E | 10% | Slow (m) | ทดสอบ User Journey ทั้งหมด | --- @@ -94,9 +94,7 @@ describe('CorrespondenceService', () => { }).compile(); service = module.get(CorrespondenceService); - repository = module.get>( - getRepositoryToken(Correspondence) - ); + repository = module.get>(getRepositoryToken(Correspondence)); }); describe('findAll', () => { @@ -106,9 +104,7 @@ describe('CorrespondenceService', () => { { id: '2', title: 'Test 2' }, ]; - jest - .spyOn(repository, 'find') - .mockResolvedValue(mockCorrespondences as any); + jest.spyOn(repository, 'find').mockResolvedValue(mockCorrespondences as any); const result = await service.findAll(); expect(result).toEqual(mockCorrespondences); @@ -570,9 +566,7 @@ describe('CorrespondenceForm', () => { vi.advanceTimersByTime(30000); await waitFor(() => { - expect(saveDraft).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Draft Test' }) - ); + expect(saveDraft).toHaveBeenCalledWith(expect.objectContaining({ title: 'Draft Test' })); }); vi.useRealTimers(); @@ -620,9 +614,7 @@ describe('useCorrespondences', () => { }); it('should handle error state', async () => { - vi.mocked(correspondenceService.getAll).mockRejectedValue( - new Error('API Error') - ); + vi.mocked(correspondenceService.getAll).mockRejectedValue(new Error('API Error')); const { result } = renderHook(() => useCorrespondences('project-1'), { wrapper, @@ -792,9 +784,7 @@ describe('Security - SQL Injection', () => { .expect(200); // Should not execute malicious SQL - const tableExists = await repository.query( - `SHOW TABLES LIKE 'correspondences'` - ); + const tableExists = await repository.query(`SHOW TABLES LIKE 'correspondences'`); expect(tableExists).toHaveLength(1); }); }); @@ -908,26 +898,22 @@ describe('Security - Authentication', () => { ### Critical Paths (Must have 100% coverage) 1. **Authentication & Authorization** - - Login/Logout flow - Token refresh - RBAC permission checks 2. **Document Numbering** - - Concurrent number generation - Format validation - Counter increment logic 3. **File Upload** - - Two-phase upload - Virus scanning - File type validation - Orphan cleanup 4. **Workflow Engine** - - State transitions - Permission checks at each step - Notification triggers @@ -1118,10 +1104,7 @@ export class CorrespondenceFactory { }; } - static createMany( - count: number, - overrides?: Partial - ): Correspondence[] { + static createMany(count: number, overrides?: Partial): Correspondence[] { return Array(count) .fill(null) .map(() => this.create(overrides)); @@ -1206,18 +1189,15 @@ describe('[ClassName/FeatureName]', () => { ### Key Metrics 1. **Coverage Percentage** - - Track via CodeCov/Coveralls - Enforce minimum thresholds in CI 2. **Test Execution Time** - - Unit tests: < 5 seconds - Integration tests: < 30 seconds - E2E tests: < 5 minutes 3. **Flaky Test Rate** - - Target: < 1% flaky tests - Track and fix flaky tests immediately diff --git a/specs/05-Engineering-Guidelines/05-05-git-cheatsheet.md b/specs/05-Engineering-Guidelines/05-05-git-cheatsheet.md index 8c59057..976660f 100644 --- a/specs/05-Engineering-Guidelines/05-05-git-cheatsheet.md +++ b/specs/05-Engineering-Guidelines/05-05-git-cheatsheet.md @@ -110,8 +110,8 @@ git pull --rebase git log ``` - --- + ## 🧩 SECTION 3 – ทำงานกับ Branch ### ดู branch ทั้งหมด @@ -231,3 +231,5 @@ git clone ssh://git@git.np-dms.work:2222/np-dms/lcbp3.git ## 📌 END ``` + +``` diff --git a/specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md b/specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md index 5b3e0a0..2c7790a 100644 --- a/specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md +++ b/specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md @@ -24,22 +24,22 @@ This document outlines the step-by-step implementation plan to integrate UUIDv7 ### Affected Tables (14) -| # | Table | PK Column | UUID Index | -|---|-------|-----------|------------| -| 1 | organizations | id | idx_organizations_uuid | -| 2 | projects | id | idx_projects_uuid | -| 3 | contracts | id | idx_contracts_uuid | -| 4 | users | user_id | idx_users_uuid | -| 5 | correspondences | id | idx_correspondences_uuid | -| 6 | correspondence_revisions | id | idx_correspondence_revisions_uuid | -| 7 | circulations | id | idx_circulations_uuid | -| 8 | shop_drawings | id | idx_shop_drawings_uuid | -| 9 | shop_drawing_revisions | id | idx_shop_drawing_revisions_uuid | -| 10 | contract_drawings | id | idx_contract_drawings_uuid | -| 11 | asbuilt_drawings | id | idx_asbuilt_drawings_uuid | -| 12 | asbuilt_drawing_revisions | id | idx_asbuilt_drawing_revisions_uuid | -| 13 | attachments | id | idx_attachments_uuid | -| 14 | notifications | id | idx_notifications_uuid | +| # | Table | PK Column | UUID Index | +| --- | ------------------------- | --------- | ---------------------------------- | +| 1 | organizations | id | idx_organizations_uuid | +| 2 | projects | id | idx_projects_uuid | +| 3 | contracts | id | idx_contracts_uuid | +| 4 | users | user_id | idx_users_uuid | +| 5 | correspondences | id | idx_correspondences_uuid | +| 6 | correspondence_revisions | id | idx_correspondence_revisions_uuid | +| 7 | circulations | id | idx_circulations_uuid | +| 8 | shop_drawings | id | idx_shop_drawings_uuid | +| 9 | shop_drawing_revisions | id | idx_shop_drawing_revisions_uuid | +| 10 | contract_drawings | id | idx_contract_drawings_uuid | +| 11 | asbuilt_drawings | id | idx_asbuilt_drawings_uuid | +| 12 | asbuilt_drawing_revisions | id | idx_asbuilt_drawing_revisions_uuid | +| 13 | attachments | id | idx_attachments_uuid | +| 14 | notifications | id | idx_notifications_uuid | ### Excluded Tables (Shared-PK / Junction — inherit UUID from parent) @@ -116,22 +116,22 @@ export class Correspondence extends UuidBaseEntity { ### Entities to Update -| Entity File | Table | -|-------------|-------| -| `organization.entity.ts` | organizations | -| `project.entity.ts` | projects | -| `contract.entity.ts` | contracts | -| `user.entity.ts` | users | -| `correspondence.entity.ts` | correspondences | -| `correspondence-revision.entity.ts` | correspondence_revisions | -| `circulation.entity.ts` | circulations | -| `shop-drawing.entity.ts` | shop_drawings | -| `shop-drawing-revision.entity.ts` | shop_drawing_revisions | -| `contract-drawing.entity.ts` | contract_drawings | -| `asbuilt-drawing.entity.ts` | asbuilt_drawings | +| Entity File | Table | +| ------------------------------------ | ------------------------- | +| `organization.entity.ts` | organizations | +| `project.entity.ts` | projects | +| `contract.entity.ts` | contracts | +| `user.entity.ts` | users | +| `correspondence.entity.ts` | correspondences | +| `correspondence-revision.entity.ts` | correspondence_revisions | +| `circulation.entity.ts` | circulations | +| `shop-drawing.entity.ts` | shop_drawings | +| `shop-drawing-revision.entity.ts` | shop_drawing_revisions | +| `contract-drawing.entity.ts` | contract_drawings | +| `asbuilt-drawing.entity.ts` | asbuilt_drawings | | `asbuilt-drawing-revision.entity.ts` | asbuilt_drawing_revisions | -| `attachment.entity.ts` | attachments | -| `notification.entity.ts` | notifications | +| `attachment.entity.ts` | attachments | +| `notification.entity.ts` | notifications | --- @@ -186,7 +186,7 @@ async findByUuid(uuid: string): Promise { ```typescript // Response DTO exposes uuid, hides id export class CorrespondenceResponseDto { - uuid: string; // ✅ Public identifier + uuid: string; // ✅ Public identifier correspondenceNumber: string; // id: number; // ❌ Never expose INT id } @@ -249,19 +249,20 @@ async findByUuidOrId(identifier: string): Promise { #### Remaining Issues -| File | Field | Entity | Issue | -|------|-------|--------|-------| -| `correspondences/form.tsx:212` | `projectId` | Project | `parseInt(p.id)` where `p.id` = UUID string (garbled number) | -| `correspondences/form.tsx:326` | `fromOrganizationId` | Organization | `parseInt(String(org.id))` where `org.id` = undefined (NaN) | -| `correspondences/form.tsx:349` | `toOrganizationId` | Organization | Same as above | -| `admin/users/page.tsx:47` | `primaryOrganizationId` (filter) | Organization | `parseInt(selectedOrgId)` where value = UUID string | -| `admin/user-dialog.tsx:226` | `primaryOrganizationId` | Organization | `parseInt(val)` where `org.id` = undefined → `"0"` fallback | -| `numbering/template-tester.tsx:71-74` | `originatorOrganizationId`, `recipientOrganizationId` | Organization | `parseInt` on org UUID | -| `rfas/page.tsx:17` | `projectId` (URL param) | Project | `parseInt(searchParams.get('projectId'))` — UUID if from URL | +| File | Field | Entity | Issue | +| ------------------------------------- | ----------------------------------------------------- | ------------ | ------------------------------------------------------------ | +| `correspondences/form.tsx:212` | `projectId` | Project | `parseInt(p.id)` where `p.id` = UUID string (garbled number) | +| `correspondences/form.tsx:326` | `fromOrganizationId` | Organization | `parseInt(String(org.id))` where `org.id` = undefined (NaN) | +| `correspondences/form.tsx:349` | `toOrganizationId` | Organization | Same as above | +| `admin/users/page.tsx:47` | `primaryOrganizationId` (filter) | Organization | `parseInt(selectedOrgId)` where value = UUID string | +| `admin/user-dialog.tsx:226` | `primaryOrganizationId` | Organization | `parseInt(val)` where `org.id` = undefined → `"0"` fallback | +| `numbering/template-tester.tsx:71-74` | `originatorOrganizationId`, `recipientOrganizationId` | Organization | `parseInt` on org UUID | +| `rfas/page.tsx:17` | `projectId` (URL param) | Project | `parseInt(searchParams.get('projectId'))` — UUID if from URL | #### Fix Strategy (same pattern as Drawing Search fix) For each affected backend DTO: + 1. Add `projectUuid?: string` / `organizationUuid?: string` field 2. Controller resolves UUID → INT id via respective service's `findOneByUuid()` 3. Frontend sends UUID string directly (remove `parseInt`) @@ -296,19 +297,19 @@ For each affected backend DTO: ## Implementation Order (Priority) -| Order | Task | Effort | Status | -|-------|------|--------|--------| -| 1 | UuidBaseEntity (no transformer needed — MariaDB native UUID) | S | ✅ Done | -| 2 | Install `uuid` package | XS | ✅ Done | -| 3 | Update 14 entity files with uuid column | M | ✅ Done | -| 4 | Create ParseUuidPipe | S | ✅ Done | -| 5 | Update controllers to use UUID params | L | ✅ Done | -| 6 | Update services with findByUuid methods | L | ✅ Done | -| 7 | Update DTOs to expose uuid, hide id | M | ✅ Done | -| 8 | Update frontend API calls & routes | L | ✅ Done | -| 9 | Drawing search: projectUuid migration | S | ✅ Done (2026-03-18) | -| 10 | FK reference UUID migration (Correspondence, User, Numbering) | M | ❌ Pending (see Phase 5.4) | -| 11 | Write unit + integration tests | M | ❌ Pending | +| Order | Task | Effort | Status | +| ----- | ------------------------------------------------------------- | ------ | -------------------------- | +| 1 | UuidBaseEntity (no transformer needed — MariaDB native UUID) | S | ✅ Done | +| 2 | Install `uuid` package | XS | ✅ Done | +| 3 | Update 14 entity files with uuid column | M | ✅ Done | +| 4 | Create ParseUuidPipe | S | ✅ Done | +| 5 | Update controllers to use UUID params | L | ✅ Done | +| 6 | Update services with findByUuid methods | L | ✅ Done | +| 7 | Update DTOs to expose uuid, hide id | M | ✅ Done | +| 8 | Update frontend API calls & routes | L | ✅ Done | +| 9 | Drawing search: projectUuid migration | S | ✅ Done (2026-03-18) | +| 10 | FK reference UUID migration (Correspondence, User, Numbering) | M | ❌ Pending (see Phase 5.4) | +| 11 | Write unit + integration tests | M | ❌ Pending | **Estimated Remaining Effort:** ~2-3 days for FK migration + ~2 days for tests diff --git a/specs/05-Engineering-Guidelines/README.md b/specs/05-Engineering-Guidelines/README.md index 767f5d1..8483412 100644 --- a/specs/05-Engineering-Guidelines/README.md +++ b/specs/05-Engineering-Guidelines/README.md @@ -50,7 +50,9 @@ ## 📖 คู่มือการพัฒนา (Implementation Guides) ### 1. [FullStack JS Guidelines](./05-01-fullstack-js-guidelines.md) + **แนวทางการพัฒนาภาพรวมทั้งระบบ (v1.8.1 — includes NestJS 11 Patterns)** + - โครงสร้างโปรเจกต์ (Monorepo-like focus) - Naming Conventions & Code Style - Secrets & Environment Management @@ -58,7 +60,9 @@ - Double-Lock Mechanism for Numbering ### 2. [Backend Guidelines](./05-02-backend-guidelines.md) + **แนวทางการพัฒนา NestJS 11 Backend** + - Modular Architecture Detail - DTO Validation & Transformer - TypeORM Best Practices & Optimistic Locking @@ -66,7 +70,9 @@ - BullMQ for Background Jobs ### 3. [Frontend Guidelines](./05-03-frontend-guidelines.md) + **แนวทางการพัฒนา Next.js 16 Frontend** + - App Router Patterns - Shadcn/UI & Tailwind Styling - TanStack Query for Data Fetching @@ -74,7 +80,9 @@ - API Client Interceptors (Auth & Idempotency) ### 4. [Document Numbering System](../01-Requirements/business-rules/01-02-02-doc-numbering-rules.md) + **รายละเอียดการนำระบบออกเลขที่เอกสารไปใช้งาน** + - Table Schema: Templates, Counters, Audit - Double-Lock Strategy (Redis Redlock + Database VersionColumn) - Reservation Flow (Phase 1: Reserve, Phase 2: Confirm) @@ -95,13 +103,13 @@ ## 🛠️ Technology Stack Recap -| Layer | Primary Technology | Secondary/Supporting | -| ------------ | ------------------ | -------------------- | -| **Backend** | NestJS 11 (Express v5) | TypeORM, BullMQ | -| **Frontend** | Next.js 16.2.0 (React 19.2.4) | Shadcn/UI, Tailwind 4.2.2 | -| **Database** | MariaDB 11.8 | Redis 7 (Cache/Lock) | -| **Search** | Elasticsearch | - | -| **Testing** | Jest, Vitest | Playwright | +| Layer | Primary Technology | Secondary/Supporting | +| ------------ | ----------------------------- | ------------------------- | +| **Backend** | NestJS 11 (Express v5) | TypeORM, BullMQ | +| **Frontend** | Next.js 16.2.0 (React 19.2.4) | Shadcn/UI, Tailwind 4.2.2 | +| **Database** | MariaDB 11.8 | Redis 7 (Cache/Lock) | +| **Search** | Elasticsearch | - | +| **Testing** | Jest, Vitest | Playwright | --- diff --git a/specs/06-Decision-Records/ADR-001-unified-workflow-engine.md b/specs/06-Decision-Records/ADR-001-unified-workflow-engine.md index 741d4b7..fcb95ad 100644 --- a/specs/06-Decision-Records/ADR-001-unified-workflow-engine.md +++ b/specs/06-Decision-Records/ADR-001-unified-workflow-engine.md @@ -222,20 +222,9 @@ CREATE TABLE workflow_histories ( ```typescript // workflow-engine.module.ts @Module({ - imports: [ - TypeOrmModule.forFeature([ - WorkflowDefinition, - WorkflowInstance, - WorkflowHistory, - ]), - UserModule, - ], + imports: [TypeOrmModule.forFeature([WorkflowDefinition, WorkflowInstance, WorkflowHistory]), UserModule], controllers: [WorkflowEngineController], - providers: [ - WorkflowEngineService, - WorkflowDslService, - WorkflowEventService, - ], + providers: [WorkflowEngineService, WorkflowDslService, WorkflowEventService], exports: [WorkflowEngineService], }) export class WorkflowEngineModule {} @@ -275,12 +264,7 @@ export class WorkflowEngineService { payload: Record = {} ) { // Evaluation via WorkflowDslService - const evaluation = this.dslService.evaluate( - compiled, - instance.currentState, - action, - context - ); + const evaluation = this.dslService.evaluate(compiled, instance.currentState, action, context); // Update state to target State instance.currentState = evaluation.nextState; diff --git a/specs/06-Decision-Records/ADR-002-document-numbering-strategy.md b/specs/06-Decision-Records/ADR-002-document-numbering-strategy.md index fc84e8f..9fe50c3 100644 --- a/specs/06-Decision-Records/ADR-002-document-numbering-strategy.md +++ b/specs/06-Decision-Records/ADR-002-document-numbering-strategy.md @@ -196,22 +196,23 @@ CREATE TABLE document_number_audit ( | Token | Description | Example Value | Database Source | | -------------- | ------------------------- | ------------------------------ | --------------------------------------------------------------------- | -| `{PROJECT}` | รหัสโครงการ | `LCBP3`, `LCBP3-C2` | `projects.project_code` | -| `{ORIGINATOR}` | รหัสองค์กรผู้ส่ง | `คคง.`, `ผรม.1` | `organizations.organization_code` via `correspondences.originator_id` | -| `{RECIPIENT}` | รหัสองค์กรผู้รับหลัก (TO) | `สคฉ.3`, `กทท.` | `organizations.organization_code` via `correspondence_recipients` | -| `{CORR_TYPE}` | รหัสประเภทเอกสาร | `RFA`, `TRANSMITTAL`, `LETTER` | `correspondence_types.type_code` | -| `{SUB_TYPE}` | หมายเลขประเภทย่อย | `11`, `12`, `21` | `correspondence_sub_types.sub_type_number` | -| `{RFA_TYPE}` | รหัสประเภท RFA | `SDW`, `RPT`, `MAT` | `rfa_types.type_code` | -| `{DISCIPLINE}` | รหัสสาขาวิชา | `STR`, `TER`, `GEO` | `disciplines.discipline_code` | +| `{PROJECT}` | รหัสโครงการ | `LCBP3`, `LCBP3-C2` | `projects.project_code` | +| `{ORIGINATOR}` | รหัสองค์กรผู้ส่ง | `คคง.`, `ผรม.1` | `organizations.organization_code` via `correspondences.originator_id` | +| `{RECIPIENT}` | รหัสองค์กรผู้รับหลัก (TO) | `สคฉ.3`, `กทท.` | `organizations.organization_code` via `correspondence_recipients` | +| `{CORR_TYPE}` | รหัสประเภทเอกสาร | `RFA`, `TRANSMITTAL`, `LETTER` | `correspondence_types.type_code` | +| `{SUB_TYPE}` | หมายเลขประเภทย่อย | `11`, `12`, `21` | `correspondence_sub_types.sub_type_number` | +| `{RFA_TYPE}` | รหัสประเภท RFA | `SDW`, `RPT`, `MAT` | `rfa_types.type_code` | +| `{DISCIPLINE}` | รหัสสาขาวิชา | `STR`, `TER`, `GEO` | `disciplines.discipline_code` | | `{SEQ:n}` | Running number (n digits) | `0001`, `0029`, `0985` | `document_number_counters.last_number + 1` | -| `{YEAR:B.E.}` | ปี พ.ศ. | `2568` | `current_year + 543` | -| `{YEAR:A.D.}` | ปี ค.ศ. | `2025` | `current_year` | +| `{YEAR:B.E.}` | ปี พ.ศ. | `2568` | `current_year + 543` | +| `{YEAR:A.D.}` | ปี ค.ศ. | `2025` | `current_year` | | `{REV}` | Revision Code | `A`, `B`, `AA` | `correspondence_revisions.revision_label` | > [!WARNING] > **Deprecated Token Names (DO NOT USE)** > > The following tokens were used in earlier drafts but are now **deprecated**: +> > - ~~`{ORG}`~~ → Use `{ORIGINATOR}` or `{RECIPIENT}` (explicit roles) > - ~~`{TYPE}`~~ → Use `{CORR_TYPE}`, `{SUB_TYPE}`, or `{RFA_TYPE}` (context-specific) > - ~~`{CATEGORY}`~~ → Not used in current system @@ -221,6 +222,7 @@ CREATE TABLE document_number_audit ( ### Format Resolution Strategy (Fallback Logic) The system resolves the numbering format using the following priority: + 1. **Specific Format:** Search for a record matching both `project_id` and `correspondence_type_id`. 2. **Default Format:** If not found, search for a record with matching `project_id` where `correspondence_type_id` is `NULL`. 3. **System Fallback:** If neither exists, use the hardcoded system default: `{ORG}-{RECIPIENT}-{SEQ:4}-{YEAR:BE}`. @@ -267,6 +269,7 @@ Counter Key: (project_id, originator_org_id, recipient_org_id, corr_type_id, sub ``` **Token Breakdown:** + - `คคง.` = `{ORIGINATOR}` - ผู้ส่ง - `สคฉ.3` = `{RECIPIENT}` - ผู้รับหลัก (TO) - `21` = `{SUB_TYPE}` - หมายเลขประเภทย่อย (11=MAT, 12=SHP, 13=DWG, 21=...) @@ -284,6 +287,7 @@ Counter Key: (project_id, originator_org_id, NULL, corr_type_id, 0, rfa_type_id, ``` **Token Breakdown:** + - `LCBP3-C2` = `{PROJECT}` - รหัสโครงการ - `RFA` = `{CORR_TYPE}` - ประเภทเอกสาร (**แสดง**ในtemplate สำหรับ RFA เท่านั้น) - `TER` = `{DISCIPLINE}` - รหัสสาขา (TER=Terminal, STR=Structure, GEO=Geotechnical) @@ -340,8 +344,8 @@ export class DocumentNumberingService { async generateNextNumber(context: NumberingContext): Promise { const year = context.year || new Date().getFullYear() + 543; // พ.ศ. - const subTypeId = context.subTypeId || 0; // Fallback for NULL - const disciplineId = context.disciplineId || 0; // Fallback for NULL + const subTypeId = context.subTypeId || 0; // Fallback for NULL + const disciplineId = context.disciplineId || 0; // Fallback for NULL // Build Redis lock key const lockKey = this.buildLockKey( @@ -355,14 +359,8 @@ export class DocumentNumberingService { // Retry with exponential backoff (Scenario 2 & 3) return this.retryWithBackoff( - async () => await this.generateNumberWithLock( - lockKey, - context, - year, - subTypeId, - disciplineId - ), - 5, // Max 5 retries + async () => await this.generateNumberWithLock(lockKey, context, year, subTypeId, disciplineId), + 5, // Max 5 retries 1000 // Initial delay 1s ); } @@ -444,12 +442,7 @@ export class DocumentNumberingService { } // Step 4: Generate formatted number - const config = await this.getConfig( - context.projectId, - context.docTypeId, - subTypeId, - disciplineId - ); + const config = await this.getConfig(context.projectId, context.docTypeId, subTypeId, disciplineId); const formattedNumber = await this.formatNumber(config.template, { ...context, @@ -471,7 +464,6 @@ export class DocumentNumberingService { this.logger.log(`Generated: ${formattedNumber} (wait: ${lockWaitMs}ms)`); return formattedNumber; - } finally { // Step 6: Release Redis lock if (lock) { @@ -506,35 +498,29 @@ export class DocumentNumberingService { } private buildLockKey(...parts: Array): string { - return `doc_num:${parts.filter(p => p !== null && p !== undefined).join(':')}`; + return `doc_num:${parts.filter((p) => p !== null && p !== undefined).join(':')}`; } // Scenario 2: Lock Acquisition Timeout - Exponential Backoff - private async retryWithBackoff( - fn: () => Promise, - maxRetries: number, - initialDelay: number - ): Promise { + private async retryWithBackoff(fn: () => Promise, maxRetries: number, initialDelay: number): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { const isRetryable = error instanceof ConflictException || - error.code === 'ECONNREFUSED' || // Scenario 4 - error.code === 'ETIMEDOUT'; // Scenario 4 + error.code === 'ECONNREFUSED' || // Scenario 4 + error.code === 'ETIMEDOUT'; // Scenario 4 if (!isRetryable || attempt === maxRetries) { if (attempt === maxRetries) { - throw new ServiceUnavailableException( - 'ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง' - ); + throw new ServiceUnavailableException('ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง'); } throw error; } const delay = initialDelay * Math.pow(2, attempt); - await new Promise(resolve => setTimeout(resolve, delay)); + await new Promise((resolve) => setTimeout(resolve, delay)); this.logger.warn(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`); } } @@ -721,8 +707,8 @@ sequenceDiagram ### Alert Conditions -| Severity | Condition | Action | -| ---------- | ---------------------------- | ------------------ | +| Severity | Condition | Action | +| ----------- | ---------------------------- | ------------------ | | 🔴 Critical | Redis unavailable >1 minute | Page ops team | | 🔴 Critical | Lock failures >10% in 5 min | Page ops team | | 🟡 Warning | Lock failures >5% in 5 min | Alert ops team | @@ -823,7 +809,7 @@ describe('DocumentNumberingService - Concurrent Generation', () => { expect(unique.size).toBe(100); // Check format - results.forEach(num => { + results.forEach((num) => { expect(num).toMatch(/^LCBP3-C2-RFI-STR-\d{4}-A$/); }); }); @@ -874,9 +860,7 @@ describe('DocumentNumberingService - Concurrent Generation', () => { it('should throw 503 after max lock acquisition retries', async () => { jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Lock timeout')); - await expect(service.generateNextNumber(context)) - .rejects - .toThrow(ServiceUnavailableException); + await expect(service.generateNextNumber(context)).rejects.toThrow(ServiceUnavailableException); }); }); ``` @@ -977,4 +961,4 @@ ensure: | 1.0 | 2025-11-30 | Initial decision | | 2.0 | 2025-12-02 | Updated with comprehensive error scenarios, monitoring, security, and all token types | | 3.0 | 2025-12-17 | Aligned with Requirements v1.6.2: updated counter schema, token definitions, Number State Machine | -| 4.0 | 2026-03-21 | Added discipline_id to formats, implemented automated Upsert logic for template management | +| 4.0 | 2026-03-21 | Added discipline_id to formats, implemented automated Upsert logic for template management | diff --git a/specs/06-Decision-Records/ADR-005-technology-stack.md b/specs/06-Decision-Records/ADR-005-technology-stack.md index e4069e3..0a7350e 100644 --- a/specs/06-Decision-Records/ADR-005-technology-stack.md +++ b/specs/06-Decision-Records/ADR-005-technology-stack.md @@ -89,31 +89,31 @@ LCBP3-DMS ต้องเลือก Technology Stack สำหรับพั #### Backend Stack -| Component | Technology | Rationale | -| :----------------- | :-------------- | :------------------------------------------------------------------------- | -| **Runtime** | Node.js 20 LTS | Stable, modern features, long-term support | -| **Framework** | NestJS 11 | Modular, TypeScript-first, Express v5 support | -| **HTTP Engine** | Express 5 | Path param changes, improved error handling | -| **Language** | TypeScript 5.x | Type safety, better DX | -| **ORM** | TypeORM | TypeScript support, migrations, repositories | -| **Database** | MariaDB 11.8 | JSON support, virtual columns, QNAP compatible | -| **Validation** | class-validator | Decorator-based, integrates with NestJS | -| **Authentication** | Passport + JWT | Standard, well-supported | +| Component | Technology | Rationale | +| :----------------- | :-------------- | :-------------------------------------------------------------------------- | +| **Runtime** | Node.js 20 LTS | Stable, modern features, long-term support | +| **Framework** | NestJS 11 | Modular, TypeScript-first, Express v5 support | +| **HTTP Engine** | Express 5 | Path param changes, improved error handling | +| **Language** | TypeScript 5.x | Type safety, better DX | +| **ORM** | TypeORM | TypeScript support, migrations, repositories | +| **Database** | MariaDB 11.8 | JSON support, virtual columns, QNAP compatible | +| **Validation** | class-validator | Decorator-based, integrates with NestJS | +| **Authentication** | Passport + JWT | Standard, well-supported | | **Authorization** | CASL **6.7.5+** | Flexible RBAC implementation ⚠️ Patched CVE-2026-1774 (Prototype Pollution) | -| **Documentation** | Swagger/OpenAPI | Auto-generated from decorators | -| **Testing** | Jest | Built-in with NestJS | +| **Documentation** | Swagger/OpenAPI | Auto-generated from decorators | +| **Testing** | Jest | Built-in with NestJS | #### Frontend Stack -| Component | Technology | Rationale | -| :-------------------- | :------------------ | :------------------------------------- | -| **Framework** | Next.js 16.2.0 | App Router, SSR/SSG, React integration | -| **UI Library** | React 19.2.4 | Industry standard, large ecosystem | -| **Language** | TypeScript 5.x | Consistency with backend | -| **Styling** | Tailwind CSS 4.2.2 | Utility-first, fast development | -| **Component Library** | shadcn/ui | Accessible, customizable, TypeScript | -| **State Management** | TanStack Query | Server state management | -| **Form Handling** | React Hook Form 7.71.2 | Performance, ต้ validation with Zod | +| Component | Technology | Rationale | +| :-------------------- | :------------------------ | :------------------------------------- | +| **Framework** | Next.js 16.2.0 | App Router, SSR/SSG, React integration | +| **UI Library** | React 19.2.4 | Industry standard, large ecosystem | +| **Language** | TypeScript 5.x | Consistency with backend | +| **Styling** | Tailwind CSS 4.2.2 | Utility-first, fast development | +| **Component Library** | shadcn/ui | Accessible, customizable, TypeScript | +| **State Management** | TanStack Query | Server state management | +| **Form Handling** | React Hook Form 7.71.2 | Performance, ต้ validation with Zod | | **Testing** | Vitest 4.1.0 + Playwright | Fast unit tests, reliable E2E | #### Infrastructure diff --git a/specs/06-Decision-Records/ADR-006-redis-caching-strategy.md b/specs/06-Decision-Records/ADR-006-redis-caching-strategy.md index f225121..68a084a 100644 --- a/specs/06-Decision-Records/ADR-006-redis-caching-strategy.md +++ b/specs/06-Decision-Records/ADR-006-redis-caching-strategy.md @@ -125,12 +125,7 @@ try { // Key: user:{user_id}:permissions // Value: JSON array of CASL rules // TTL: 30 minutes -await redis.set( - `user:${userId}:permissions`, - JSON.stringify(abilityRules), - 'EX', - 1800 -); +await redis.set(`user:${userId}:permissions`, JSON.stringify(abilityRules), 'EX', 1800); ``` **Invalidation Strategy:** diff --git a/specs/06-Decision-Records/ADR-008-email-notification-strategy.md b/specs/06-Decision-Records/ADR-008-email-notification-strategy.md index 41f263f..700b10b 100644 --- a/specs/06-Decision-Records/ADR-008-email-notification-strategy.md +++ b/specs/06-Decision-Records/ADR-008-email-notification-strategy.md @@ -253,10 +253,28 @@ export class EmailProcessor { diff --git a/specs/06-Decision-Records/ADR-009-database-migration-strategy.md b/specs/06-Decision-Records/ADR-009-database-migration-strategy.md index ea95983..8614083 100644 --- a/specs/06-Decision-Records/ADR-009-database-migration-strategy.md +++ b/specs/06-Decision-Records/ADR-009-database-migration-strategy.md @@ -159,16 +159,9 @@ git commit -m "feat: add discipline_id to correspondences" ```typescript // File: backend/src/migrations/1234567890-AddDisciplineIdToCorrespondences.ts -import { - MigrationInterface, - QueryRunner, - TableColumn, - TableForeignKey, -} from 'typeorm'; +import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm'; -export class AddDisciplineIdToCorrespondences1234567890 - implements MigrationInterface -{ +export class AddDisciplineIdToCorrespondences1234567890 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // Add column await queryRunner.addColumn( @@ -192,21 +185,15 @@ export class AddDisciplineIdToCorrespondences1234567890 ); // Add index - await queryRunner.query( - 'CREATE INDEX idx_correspondences_discipline_id ON correspondences(discipline_id)' - ); + await queryRunner.query('CREATE INDEX idx_correspondences_discipline_id ON correspondences(discipline_id)'); } public async down(queryRunner: QueryRunner): Promise { // Reverse order: index → FK → column - await queryRunner.query( - 'DROP INDEX idx_correspondences_discipline_id ON correspondences' - ); + await queryRunner.query('DROP INDEX idx_correspondences_discipline_id ON correspondences'); const table = await queryRunner.getTable('correspondences'); - const foreignKey = table.foreignKeys.find( - (fk) => fk.columnNames.indexOf('discipline_id') !== -1 - ); + const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.indexOf('discipline_id') !== -1); await queryRunner.dropForeignKey('correspondences', foreignKey); await queryRunner.dropColumn('correspondences', 'discipline_id'); @@ -303,9 +290,7 @@ describe('Migrations', () => { // Verify tables exist const tables = await dataSource.query('SHOW TABLES'); - expect(tables).toContainEqual( - expect.objectContaining({ Tables_in_lcbp3: 'correspondences' }) - ); + expect(tables).toContainEqual(expect.objectContaining({ Tables_in_lcbp3: 'correspondences' })); }); it('should rollback all migrations successfully', async () => { diff --git a/specs/06-Decision-Records/ADR-010-logging-monitoring-strategy.md b/specs/06-Decision-Records/ADR-010-logging-monitoring-strategy.md index fc98a6f..68e837e 100644 --- a/specs/06-Decision-Records/ADR-010-logging-monitoring-strategy.md +++ b/specs/06-Decision-Records/ADR-010-logging-monitoring-strategy.md @@ -131,9 +131,7 @@ export const logger = winston.createLogger({ format: winston.format.combine( winston.format.colorize(), winston.format.printf(({ timestamp, level, message, ...meta }) => { - return `${timestamp} [${level}]: ${message} ${ - Object.keys(meta).length ? JSON.stringify(meta) : '' - }`; + return `${timestamp} [${level}]: ${message} ${Object.keys(meta).length ? JSON.stringify(meta) : ''}`; }) ), }), @@ -240,10 +238,7 @@ export class RequestLoggerMiddleware implements NestMiddleware { // File: backend/src/config/database.config.ts export default { // ... - logging: - process.env.NODE_ENV === 'development' - ? 'all' - : ['error', 'warn', 'schema'], + logging: process.env.NODE_ENV === 'development' ? 'all' : ['error', 'warn', 'schema'], logger: 'advanced-console', maxQueryExecutionTime: 1000, // Warn if query > 1s }; @@ -299,12 +294,7 @@ logger.verbose('Cache hit', { key, ttl }); ```typescript // File: backend/src/common/interceptors/performance.interceptor.ts -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { logger } from 'src/config/logger.config'; diff --git a/specs/06-Decision-Records/ADR-013-form-handling-validation.md b/specs/06-Decision-Records/ADR-013-form-handling-validation.md index 018ab93..5397d15 100644 --- a/specs/06-Decision-Records/ADR-013-form-handling-validation.md +++ b/specs/06-Decision-Records/ADR-013-form-handling-validation.md @@ -119,10 +119,7 @@ export const correspondenceSchema = z.object({ .min(5, 'Subject must be at least 5 characters') .max(255, 'Subject must not exceed 255 characters'), - description: z - .string() - .min(10, 'Description must be at least 10 characters') - .optional(), + description: z.string().min(10, 'Description must be at least 10 characters').optional(), document_type_id: z.number({ required_error: 'Document type is required', @@ -402,16 +399,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true }); } catch (error) { if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation failed', issues: error.errors }, - { status: 400 } - ); + return NextResponse.json({ error: 'Validation failed', issues: error.errors }, { status: 400 }); } - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } ``` diff --git a/specs/06-Decision-Records/ADR-014-state-management.md b/specs/06-Decision-Records/ADR-014-state-management.md index d701976..3856ede 100644 --- a/specs/06-Decision-Records/ADR-014-state-management.md +++ b/specs/06-Decision-Records/ADR-014-state-management.md @@ -219,10 +219,7 @@ export const useNotificationStore = create((set) => ({ addNotification: (notification) => set((state) => ({ - notifications: [ - ...state.notifications, - { ...notification, id: Math.random().toString() }, - ], + notifications: [...state.notifications, { ...notification, id: Math.random().toString() }], })), removeNotification: (id) => @@ -312,8 +309,7 @@ export const useUIStore = create()( sidebarCollapsed: false, theme: 'light', - toggleSidebar: () => - set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })), + toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })), setTheme: (theme) => set({ theme }), }), diff --git a/specs/06-Decision-Records/ADR-016-security-authentication.md b/specs/06-Decision-Records/ADR-016-security-authentication.md index e3bc3af..247388a 100644 --- a/specs/06-Decision-Records/ADR-016-security-authentication.md +++ b/specs/06-Decision-Records/ADR-016-security-authentication.md @@ -39,7 +39,7 @@ LCBP3-DMS จัดการเอกสารสำคัญของโปร **Chosen:** **JWT (JSON Web Tokens) with Bearer Token Strategy (Stored in LocalStorage via Zustand)** -*Note: Initial plan was HTTP-only cookies, but shifted to Bearer tokens to ease cross-domain Next.js to NestJS communication.* +_Note: Initial plan was HTTP-only cookies, but shifted to Bearer tokens to ease cross-domain Next.js to NestJS communication._ ```typescript // File: src/auth/auth.service.ts @@ -85,10 +85,7 @@ export class AuthService { if (!user) return null; // Use bcrypt for password comparison - const isValid = await bcrypt.compare( - credentials.password, - user.password_hash - ); + const isValid = await bcrypt.compare(credentials.password, user.password_hash); return isValid ? user : null; } @@ -99,7 +96,7 @@ export class AuthService { **Strategy:** **bcrypt with salt rounds = 10 (Current implementation defaults to 10 via `genSalt()`)** -*Note: Code currently uses `bcrypt.genSalt()` without arguments, defaulting to 10 rounds. If 12 is strictly required, codebase needs updating.* +_Note: Code currently uses `bcrypt.genSalt()` without arguments, defaulting to 10 rounds. If 12 is strictly required, codebase needs updating._ ```typescript import * as bcrypt from 'bcrypt'; @@ -112,10 +109,7 @@ async function hashPassword(password: string): Promise { } // Verify password -async function verifyPassword( - password: string, - hash: string -): Promise { +async function verifyPassword(password: string, hash: string): Promise { return bcrypt.compare(password, hash); } ``` @@ -177,11 +171,7 @@ function encrypt(text: string): { encrypted: string; iv: string; tag: string } { } function decrypt(encrypted: string, iv: string, tag: string): string { - const decipher = crypto.createDecipheriv( - algorithm, - key, - Buffer.from(iv, 'hex') - ); + const decipher = crypto.createDecipheriv(algorithm, key, Buffer.from(iv, 'hex')); decipher.setAuthTag(Buffer.from(tag, 'hex')); diff --git a/specs/06-Decision-Records/ADR-017-ollama-data-migration.md b/specs/06-Decision-Records/ADR-017-ollama-data-migration.md index c9b931d..3e655e4 100644 --- a/specs/06-Decision-Records/ADR-017-ollama-data-migration.md +++ b/specs/06-Decision-Records/ADR-017-ollama-data-migration.md @@ -5,11 +5,13 @@ **Version:** 1.8.0 **Decision Makers:** Development Team, DevOps Engineer **Related Documents:** + - [Legacy Data Migration Plan](../03-Data-and-Storage/03-04-legacy-data-migration.md) - [n8n Migration Setup Guide](../03-Data-and-Storage/03-05-n8n-migration-setup-guide.md) - [Software Architecture](../02-Architecture/02-02-software-architecture.md) - [Data Dictionary](../03-Data-and-Storage/03-01-data-dictionary.md) -> **Note:** ADR-017 is clarified and hardened by ADR-018 regarding AI physical isolation. Category Enum system-driven, Idempotency Contract, Duplicate Handling Clarification, Storage Enforcement, Audit Log Enhancement, Review Queue Integration, Revision Drift Protection, Execution Time, Encoding Normalization, Security Hardening, Orchestrator on QNAP, AI Physical Isolation (Desktop Desk-5439). + > **Note:** ADR-017 is clarified and hardened by ADR-018 regarding AI physical isolation. Category Enum system-driven, Idempotency Contract, Duplicate Handling Clarification, Storage Enforcement, Audit Log Enhancement, Review Queue Integration, Revision Drift Protection, Execution Time, Encoding Normalization, Security Hardening, Orchestrator on QNAP, AI Physical Isolation (Desktop Desk-5439). + --- ## Context and Problem Statement @@ -19,6 +21,7 @@ ความท้าทายหลักคือ **Data Integrity และความถูกต้องของ Metadata** เนื่องจากข้อมูลเก่ามีโอกาสเกิด Human Error เราจึงต้องการ AI ช่วย Validate ก่อนนำเข้า การส่งข้อมูลขึ้น Cloud AI Provider มีปัญหา 2 ประการ: + 1. **Data Privacy:** เอกสารก่อสร้างท่าเรือเป็นความลับ ห้ามออกนอกเครือข่าย 2. **Cost:** ~$0.01–0.03 ต่อ Record = อาจสูงถึง $600 สำหรับ 20,000 records @@ -44,6 +47,7 @@ **Pros:** ไม่ต้องจัดหา Hardware เพิ่ม, AI ฉลาดสูง **Cons:** + - ❌ ผิดนโยบาย Data Privacy - ❌ ค่าใช้จ่ายสูง (~$600) - ❌ Code สกปรก ปะปนกับ Source Code หลัก @@ -53,12 +57,14 @@ **Pros:** เร็ว ไม่มีค่าใช้จ่าย **Cons:** + - ❌ ความแม่นยำต่ำ ตรวจได้แค่ Format - ❌ ต้องใช้ Manual Review จำนวนมาก ### Option 3: Local AI Model (Ollama) + n8n ⭐ (Selected) **Pros:** + - ✅ Privacy Guaranteed - ✅ Zero Cost - ✅ Clean Architecture @@ -67,6 +73,7 @@ - ✅ Structured Output ด้วย JSON Schema **Cons:** + - ❌ ต้องเปิด Desktop ทิ้งไว้ดูแล GPU Temperature - ❌ Model เล็กอาจแม่นน้อยกว่า Cloud AI → ต้องมี Human Review Queue @@ -82,19 +89,19 @@ ## Implementation Summary -| Component | รายละเอียด | -| ---------------------- | ------------------------------------------------------------------------------- | -| Migration Orchestrator | n8n (Docker บน QNAP NAS) | -| AI Model Primary | Ollama `llama3.2:3b` (Validation, Summarization, Tagging) | -| AI Model Fallback | Ollama `mistral:7b-instruct-q4_K_M` | -| Hardware | QNAP NAS (Orchestrator) + Desktop Desk-5439 (AI Processing, RTX 2060 SUPER 8GB) | -| DB Lookup (n8n) | n8n ทำการ Query `project_id`, `organization_id` และดึง `Tags` จาก DB ให้ AI | +| Component | รายละเอียด | +| ---------------------- | ------------------------------------------------------------------------------------------------------------ | +| Migration Orchestrator | n8n (Docker บน QNAP NAS) | +| AI Model Primary | Ollama `llama3.2:3b` (Validation, Summarization, Tagging) | +| AI Model Fallback | Ollama `mistral:7b-instruct-q4_K_M` | +| Hardware | QNAP NAS (Orchestrator) + Desktop Desk-5439 (AI Processing, RTX 2060 SUPER 8GB) | +| DB Lookup (n8n) | n8n ทำการ Query `project_id`, `organization_id` และดึง `Tags` จาก DB ให้ AI | | Data Ingestion | 1. Staging ลง `migration_review_queue` -> 2. กดยืนยันผ่าน Frontend Management UI -> 3. Final Commit ผ่าน API | -| Concurrency (n8n) | Sequential — Batch Size 50-100 ป้องกัน DB Connection Overload | -| Checkpoint | MariaDB `migration_progress` และการใช้ `ON DUPLICATE KEY UPDATE` ใน Staging | -| Fallback | Auto-switch Model เมื่อ Error ≥ Threshold | -| Storage | Two-Phase Storage: 1. `POST /api/storage/upload` (Temp) -> 2. Commit ภายหลัง | -| Expected Runtime | ~16.6 ชั่วโมง (~3–4 คืน) สำหรับ 20,000 records | +| Concurrency (n8n) | Sequential — Batch Size 50-100 ป้องกัน DB Connection Overload | +| Checkpoint | MariaDB `migration_progress` และการใช้ `ON DUPLICATE KEY UPDATE` ใน Staging | +| Fallback | Auto-switch Model เมื่อ Error ≥ Threshold | +| Storage | Two-Phase Storage: 1. `POST /api/storage/upload` (Temp) -> 2. Commit ภายหลัง | +| Expected Runtime | ~16.6 ชั่วโมง (~3–4 คืน) สำหรับ 20,000 records | --- @@ -115,14 +122,14 @@ } ``` -| Field | Type | คำอธิบาย | -| -------------------- | ------------------------- | --------------------------- | -| `is_valid` | boolean | เอกสารผ่านการตรวจสอบหรือไม่ (เปรียบเทียบ subject vs pdf) | -| `confidence` | float (0.0–1.0) | ความมั่นใจของ AI | -| `suggested_category` | string (enum จาก Backend) | หมวดหมู่ที่ AI แนะนำ | -| `detected_issues` | string[] | รายการปัญหา (array ว่างถ้าไม่มี) | -| `suggested_title` | string \| null | Title ที่แก้ไขแล้ว หรือ null | -| `summary` | string | สรุปเนื้อหา 4-5 ประโยค สำหรับใส่ใน `body` | +| Field | Type | คำอธิบาย | +| -------------------- | ------------------------- | ---------------------------------------------------------------- | +| `is_valid` | boolean | เอกสารผ่านการตรวจสอบหรือไม่ (เปรียบเทียบ subject vs pdf) | +| `confidence` | float (0.0–1.0) | ความมั่นใจของ AI | +| `suggested_category` | string (enum จาก Backend) | หมวดหมู่ที่ AI แนะนำ | +| `detected_issues` | string[] | รายการปัญหา (array ว่างถ้าไม่มี) | +| `suggested_title` | string \| null | Title ที่แก้ไขแล้ว หรือ null | +| `summary` | string | สรุปเนื้อหา 4-5 ประโยค สำหรับใส่ใน `body` | | `suggested_tags` | array of objects | รายการ Tags ที่จับคู่ได้ หรือ แนะนำให้สร้างใหม่ (`is_new: true`) | > ⚠️ **Patch:** `suggested_category` ต้องตรงกับ System Enum จาก `GET /api/meta/categories` เท่านั้น — ห้าม hardcode Category List ใน Prompt @@ -133,13 +140,13 @@ **ข้อมูลทุกชุดจาก n8n จะต้องถูกส่งเข้าตาราง `migration_review_queue` เสมอ** โดยจัดสถานะเบื้องต้นตาม Confidence: -| ระดับ Confidence | สถานะใน Review Queue | -| ------------------------------- | --------------------------------------- | -| `>= 0.85` และ `is_valid = true` | `PENDING` (พร้อมให้ Admin เลือก Batch Import) | -| `0.60–0.84` | `PENDING` (ไฮไลต์แจ้งให้ Admin ตรวจสอบข้อมูลก่อน) | -| `< 0.60` หรือ `is_valid = false` | `REJECTED` (รอให้ Admin แก้ไขข้อมูล Manual) | -| AI Parse Error | ส่งไป Error Log + Trigger Fallback Logic | -| Revision Drift | `PENDING` พร้อมระบุ reason: "Revision drift" | +| ระดับ Confidence | สถานะใน Review Queue | +| -------------------------------- | ------------------------------------------------- | +| `>= 0.85` และ `is_valid = true` | `PENDING` (พร้อมให้ Admin เลือก Batch Import) | +| `0.60–0.84` | `PENDING` (ไฮไลต์แจ้งให้ Admin ตรวจสอบข้อมูลก่อน) | +| `< 0.60` หรือ `is_valid = false` | `REJECTED` (รอให้ Admin แก้ไขข้อมูล Manual) | +| AI Parse Error | ส่งไป Error Log + Trigger Fallback Logic | +| Revision Drift | `PENDING` พร้อมระบุ reason: "Revision drift" | > ⚠️ **Tag Review:** ข้อมูลใดที่มี `is_new: true` ใน `suggested_tags` จะถูกบังคับให้ Admin ตรวจสอบบน Frontend UI ก่อน เพื่อป้องกัน AI สร้าง Tags ขยะซ้ำซ้อน @@ -148,11 +155,13 @@ ## Idempotency Contract **HTTP Header ที่ต้องส่งทุก Request:** + ``` Idempotency-Key: : ``` **Backend Logic:** + ``` IF idempotency_key EXISTS in import_transactions → RETURN HTTP 200 (no action) ELSE → Process normally → INSERT import_transactions → RETURN HTTP 201 @@ -167,6 +176,7 @@ ELSE → Process normally → INSERT import_transactions → RETURN HTTP 201 Bypass Duplicate **Validation Error** Hard Rules: + - ❌ Migration Token ไม่สามารถ Overwrite Revision ที่มีอยู่ - ❌ Migration Token ไม่สามารถ Delete Revision ก่อนหน้า - ✅ Migration Token trigger Revision increment logic ตามปกติเท่านั้น @@ -176,6 +186,7 @@ Hard Rules: ## Storage Governance (Two-Phase Storage) **ข้อห้าม:** + ``` ❌ mv /data/dms/staging_ai/TCC-COR-0001.pdf /final/path/... ``` @@ -183,6 +194,7 @@ Hard Rules: **ข้อบังคับ (Two-Phase Strategy):** **Phase 1: Temp Upload (โดย n8n)** + ``` ✅ POST /api/storage/upload (Upload ไฟล์ PDF ได้ผลลัพธ์เป็น attachment_id เช่น 1024) @@ -190,12 +202,14 @@ Hard Rules: ``` **Phase 2: Final Commit (โดย Frontend UI -> Backend API)** + ``` ✅ POST /api/migration/commit_batch body: { queue_ids: [1, 2, 3] } ``` Backend จะทำหน้าที่: + 1. อ่านข้อมูลจาก `migration_review_queue` ซึ่งมี `temp_attachment_id` อยู่ 2. นำ `temp_attachment_id` ไปเชื่อมกับเอกสาร (Link to `correspondence_attachments`) 3. เปลี่ยนสถานะอัพเดต `is_temporary = FALSE` @@ -206,8 +220,8 @@ Backend จะทำหน้าที่: ## Review Queue Contract & Frontend UI - `migration_review_queue` เป็น **Staging Table หลัก** (ไม่ auto-ingest ข้ามขั้นตอนนี้) -- ห้ามสร้าง Correspondence record จนกว่า Admin จะสั่ง Execute การ Import จากหน้าจอ -- **Approval Flow:** +- ห้ามสร้าง Correspondence record จนกว่า Admin จะสั่ง Execute การ Import จากหน้าจอ +- **Approval Flow:** 1. N8N Insert เข้า `migration_review_queue` (พร้อม `temp_attachment_id`) 2. Admin Review บน Frontend UI (ให้ความสำคัญกับการเช็ค `is_new: true` Tags) 3. Admin เลือก Rows แล้วกด **"Execute Import"** @@ -218,6 +232,7 @@ Backend จะทำหน้าที่: ## Revision Drift Protection ถ้า Excel มี revision column: + ``` IF excel_revision != current_db_revision + 1 → ROUTE ไป Review Queue พร้อม reason: "Revision drift" @@ -227,20 +242,21 @@ IF excel_revision != current_db_revision + 1 ## Execution Time Estimate -| Parameter | ค่า | -| -------------------- | ---------------------------- | -| Delay ระหว่าง Request | 2 วินาที | -| Inference Time (avg) | ~1 วินาที | -| เวลาต่อ Record | ~3 วินาที | -| จำนวน Record | 20,000 | -| เวลารวม | ~60,000 วินาที (~16.6 ชั่วโมง) | -| **จำนวนคืนที่ต้องใช้** | **~3–4 คืน** (รัน 22:00–06:00) | +| Parameter | ค่า | +| ---------------------- | ------------------------------ | +| Delay ระหว่าง Request | 2 วินาที | +| Inference Time (avg) | ~1 วินาที | +| เวลาต่อ Record | ~3 วินาที | +| จำนวน Record | 20,000 | +| เวลารวม | ~60,000 วินาที (~16.6 ชั่วโมง) | +| **จำนวนคืนที่ต้องใช้** | **~3–4 คืน** (รัน 22:00–06:00) | --- ## Encoding Normalization ก่อน Ingestion ทุกครั้ง: + - Excel data → Convert เป็น **UTF-8** - Filename → Normalize เป็น **NFC UTF-8** ป้องกันปัญหาภาษาไทยเพี้ยนข้าม OS @@ -274,7 +290,7 @@ IF excel_revision != current_db_revision + 1 ### 🟢 A. Infrastructure Validation -| Check | Expected | ✅ | +| Check | Expected | ✅ | | ---------------------------- | ------------- | --- | | Ollama `/api/tags` reachable | HTTP 200 | | | Backend `/health` OK | HTTP 200 | | @@ -286,7 +302,7 @@ IF excel_revision != current_db_revision + 1 ### 🟢 B. Security Validation -| Check | Expected | ✅ | +| Check | Expected | ✅ | | -------------------------------------- | -------- | --- | | Migration Token expiry ≤ 7 days | Verified | | | Token IP Whitelist = NAS IP only | Verified | | @@ -298,7 +314,7 @@ IF excel_revision != current_db_revision + 1 ### 🟢 C. Data Integrity Validation -| Check | Expected | ✅ | +| Check | Expected | ✅ | | ---------------------------------------------- | -------------- | --- | | Enum fetched from `/api/meta/categories` | Not hardcoded | | | `Idempotency-Key` header enforced | Verified | | @@ -309,7 +325,7 @@ IF excel_revision != current_db_revision + 1 ### 🟢 D. Workflow Validation (Dry Run 20 Records) -| Check | Expected | ✅ | +| Check | Expected | ✅ | | ---------------------------------------- | ------------ | --- | | JSON parse success rate | > 95% | | | Confidence distribution reasonable | Mean 0.7–0.9 | | @@ -321,7 +337,7 @@ IF excel_revision != current_db_revision + 1 ### 🟢 E. Performance Validation -| Check | Expected | ✅ | +| Check | Expected | ✅ | | ------------------------------- | -------- | --- | | 10 records processed < 1 minute | Verified | | | GPU temp < 80°C | Verified | | @@ -330,7 +346,7 @@ IF excel_revision != current_db_revision + 1 ### 🟢 F. Rollback Test (Mandatory) -| Check | Expected | ✅ | +| Check | Expected | ✅ | | ------------------------------------ | ----------------- | --- | | Disable token works | is_active = false | | | Delete `SYSTEM_IMPORT` records works | COUNT = 0 | | @@ -343,12 +359,14 @@ IF excel_revision != current_db_revision + 1 ## GO / NO-GO Criteria **GO ถ้า:** + - A, B, C ทุก Check = PASS - Dry run error rate < 10% - JSON parse failure < 5% - Revision conflict < 3% **NO-GO ถ้า:** + - Enum mismatch (Category hardcoded) - Idempotency ไม่ได้ implement - Storage bypass (move file โดยตรง) @@ -358,8 +376,8 @@ IF excel_revision != current_db_revision + 1 ## Final Architectural Assessment -| Area | Status | -| ------------------ | ------------------------------------------------ | +| Area | Status | +| ------------------ | ------------------------------------------------- | | ADR Compliance | ✅ Fully aligned | | Security | ✅ Hardened (IP Whitelist, Rate Limit, Docker) | | Data Integrity | ✅ Controlled (Idempotency, Revision Drift, Enum) | @@ -368,4 +386,4 @@ IF excel_revision != current_db_revision + 1 --- -*สำหรับขั้นตอนปฏิบัติงานแบบละเอียด ดูที่ `03-04-legacy-data-migration.md` และ `03-05-n8n-migration-setup-guide.md`* +_สำหรับขั้นตอนปฏิบัติงานแบบละเอียด ดูที่ `03-04-legacy-data-migration.md` และ `03-05-n8n-migration-setup-guide.md`_ diff --git a/specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md b/specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md index e61528f..dca1a54 100644 --- a/specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md +++ b/specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md @@ -5,6 +5,7 @@ **Version:** 1.8.1 **Decision Makers:** Development Team, Database Architect **Related Documents:** + - [Data Dictionary](../03-Data-and-Storage/03-01-data-dictionary.md) - [Database Schema](../03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql) - [ADR-005: Technology Stack](ADR-005-technology-stack.md) @@ -41,9 +42,11 @@ ### Option 1: Replace INT with UUID as Primary Key **Pros:** + - ✅ Opaque identifier ทุกที่ **Cons:** + - ❌ FK ทั้งหมดต้องเปลี่ยนเป็น BINARY(16) — Migration ซับซ้อนมาก - ❌ JOIN Performance แย่ลง (16 bytes vs 4 bytes) - ❌ InnoDB Clustered Index ไม่เรียงลำดับตาม INSERT Time (UUIDv4) @@ -53,9 +56,11 @@ ### Option 2: UUID as String Column (CHAR(36)) **Pros:** + - ✅ Human-readable **Cons:** + - ❌ ใช้พื้นที่ 36 bytes ต่อ row (vs 16 bytes สำหรับ BINARY) - ❌ Index ใหญ่ ช้ากว่า BINARY(16) อย่างมีนัยสำคัญ - ❌ Collation issues กับ case-sensitivity @@ -63,6 +68,7 @@ ### Option 3: Hybrid INT + UUID (MariaDB Native) ⭐ (Selected) **Pros:** + - ✅ INT PK ยังเป็น Internal ID → Performance ไม่เปลี่ยน - ✅ UUID เป็น External ID → ปลอดภัย + Space-efficient (BINARY(16) ภายใน) - ✅ ไม่ต้อง Migrate FK Relationships @@ -71,6 +77,7 @@ - ✅ ไม่กระทบ Migration Tables (Temporary) **Cons:** + - ❌ ต้องเพิ่ม Column ใหม่ + UNIQUE INDEX ทุก Public-Facing Table - ❌ Application Layer ต้อง Generate UUIDv7 ตอน INSERT - ❌ API Layer ต้อง Resolve UUID → INT สำหรับ Internal Queries @@ -89,14 +96,14 @@ ### 1. UUID Format -| Property | Value | -|----------|-------| -| **Type** | MariaDB Native `UUID` (available since 10.7) | -| **Storage** | `BINARY(16)` internally (automatic) | -| **DB Default** | `UUID()` — generates UUID v1 (time-based, fallback for seed data) | -| **App Generation** | NestJS `@BeforeInsert()` generates UUIDv7 (RFC 9562) for time-ordering | -| **Display** | Auto-converts to string format (8-4-4-4-12) — no conversion function needed | -| **Index** | `UNIQUE INDEX` on `uuid` column | +| Property | Value | +| ------------------ | --------------------------------------------------------------------------- | +| **Type** | MariaDB Native `UUID` (available since 10.7) | +| **Storage** | `BINARY(16)` internally (automatic) | +| **DB Default** | `UUID()` — generates UUID v1 (time-based, fallback for seed data) | +| **App Generation** | NestJS `@BeforeInsert()` generates UUIDv7 (RFC 9562) for time-ordering | +| **Display** | Auto-converts to string format (8-4-4-4-12) — no conversion function needed | +| **Index** | `UNIQUE INDEX` on `uuid` column | ### 2. Column Specification @@ -116,38 +123,38 @@ UNIQUE INDEX idx_{table}_uuid (uuid) #### Tier 1 — Core Entity Tables (Own UUID Column) -| # | Table Name | Current PK | UUID Column | Notes | -|---|-----------|-----------|-------------|-------| -| 1 | `users` | `user_id INT AI` | `uuid UUID` | User profiles | -| 2 | `organizations` | `id INT AI` | `uuid UUID` | Organization data | -| 3 | `projects` | `id INT AI` | `uuid UUID` | Project data | -| 4 | `contracts` | `id INT AI` | `uuid UUID` | Contract data | -| 5 | `correspondences` | `id INT AI` | `uuid UUID` | Main document entity | -| 6 | `correspondence_revisions` | `id INT AI` | `uuid UUID` | Document versions | -| 7 | `circulations` | `id INT AI` | `uuid UUID` | Internal circulations | -| 8 | `shop_drawings` | `id INT AI` | `uuid UUID` | Shop drawing master | -| 9 | `shop_drawing_revisions` | `id INT AI` | `uuid UUID` | Shop drawing versions | -| 10 | `contract_drawings` | `id INT AI` | `uuid UUID` | Contract drawing master | -| 11 | `asbuilt_drawings` | `id INT AI` | `uuid UUID` | As-built drawing master | -| 12 | `asbuilt_drawing_revisions` | `id INT AI` | `uuid UUID` | As-built drawing versions | -| 13 | `attachments` | `id INT AI` | `uuid UUID` | File attachments | -| 14 | `notifications` | `id INT AI` (partitioned) | `uuid UUID` | User notifications | +| # | Table Name | Current PK | UUID Column | Notes | +| --- | --------------------------- | ------------------------- | ----------- | ------------------------- | +| 1 | `users` | `user_id INT AI` | `uuid UUID` | User profiles | +| 2 | `organizations` | `id INT AI` | `uuid UUID` | Organization data | +| 3 | `projects` | `id INT AI` | `uuid UUID` | Project data | +| 4 | `contracts` | `id INT AI` | `uuid UUID` | Contract data | +| 5 | `correspondences` | `id INT AI` | `uuid UUID` | Main document entity | +| 6 | `correspondence_revisions` | `id INT AI` | `uuid UUID` | Document versions | +| 7 | `circulations` | `id INT AI` | `uuid UUID` | Internal circulations | +| 8 | `shop_drawings` | `id INT AI` | `uuid UUID` | Shop drawing master | +| 9 | `shop_drawing_revisions` | `id INT AI` | `uuid UUID` | Shop drawing versions | +| 10 | `contract_drawings` | `id INT AI` | `uuid UUID` | Contract drawing master | +| 11 | `asbuilt_drawings` | `id INT AI` | `uuid UUID` | As-built drawing master | +| 12 | `asbuilt_drawing_revisions` | `id INT AI` | `uuid UUID` | As-built drawing versions | +| 13 | `attachments` | `id INT AI` | `uuid UUID` | File attachments | +| 14 | `notifications` | `id INT AI` (partitioned) | `uuid UUID` | User notifications | #### Tier 2 — Shared-PK Tables (Inherit UUID from Parent) -| # | Table Name | Shared PK Source | UUID Resolution | -|---|-----------|-----------------|-----------------| -| 1 | `rfas` | `correspondences.id` | Use `correspondences.uuid` | -| 2 | `rfa_revisions` | `correspondence_revisions.id` | Use `correspondence_revisions.uuid` | -| 3 | `transmittals` | `correspondences.id` | Use `correspondences.uuid` | +| # | Table Name | Shared PK Source | UUID Resolution | +| --- | --------------- | ----------------------------- | ----------------------------------- | +| 1 | `rfas` | `correspondences.id` | Use `correspondences.uuid` | +| 2 | `rfa_revisions` | `correspondence_revisions.id` | Use `correspondence_revisions.uuid` | +| 3 | `transmittals` | `correspondences.id` | Use `correspondences.uuid` | #### Already Using UUID — No Changes Needed -| Table Name | Current PK | -|-----------|-----------| +| Table Name | Current PK | +| ---------------------- | --------------- | | `workflow_definitions` | `CHAR(36) UUID` | -| `workflow_instances` | `CHAR(36) UUID` | -| `workflow_histories` | `CHAR(36) UUID` | +| `workflow_instances` | `CHAR(36) UUID` | +| `workflow_histories` | `CHAR(36) UUID` | #### Excluded Tables (Internal/Master/Junction) @@ -372,14 +379,14 @@ ALTER TABLE notifications DROP INDEX idx_notifications_uuid, DROP COLUMN uuid; ## Storage Impact Analysis -| Item | Size | -|------|------| -| UUID (BINARY(16) internal) per row | 16 bytes | -| UNIQUE INDEX per row | ~16 bytes (key) + ~6 bytes (pointer) ≈ 22 bytes | -| **Total per row** | **~38 bytes** | -| Estimated rows (all 14 tables combined) | ~100,000 (Year 1) | -| **Total additional storage** | **~3.8 MB** | -| Impact on QNAP NAS | **Negligible** | +| Item | Size | +| --------------------------------------- | ----------------------------------------------- | +| UUID (BINARY(16) internal) per row | 16 bytes | +| UNIQUE INDEX per row | ~16 bytes (key) + ~6 bytes (pointer) ≈ 22 bytes | +| **Total per row** | **~38 bytes** | +| Estimated rows (all 14 tables combined) | ~100,000 (Year 1) | +| **Total additional storage** | **~3.8 MB** | +| Impact on QNAP NAS | **Negligible** | --- @@ -387,12 +394,12 @@ ALTER TABLE notifications DROP INDEX idx_notifications_uuid, DROP COLUMN uuid; ### UUIDv7 vs UUIDv4 for B-tree Index -| Property | UUIDv4 | UUIDv7 | -|----------|--------|--------| -| Ordering | Random | Time-ordered | -| B-tree insert | Random page splits | Sequential append | -| Index fragmentation | High | Low | -| Cache efficiency | Poor | Good | +| Property | UUIDv4 | UUIDv7 | +| ------------------- | ------------------ | ----------------- | +| Ordering | Random | Time-ordered | +| B-tree insert | Random page splits | Sequential append | +| Index fragmentation | High | Low | +| Cache efficiency | Poor | Good | **UUIDv7 ถูกเลือกเพราะ Time-ordering** ทำให้ INSERT ไม่ทำให้เกิด Random Page Split บน InnoDB B-tree ซึ่งสำคัญมากสำหรับ QNAP NAS ที่มี I/O จำกัด @@ -416,46 +423,50 @@ WHERE c.uuid = '019505a1-7c3e-7000-8000-abc123def456'; ## Security Benefits -| Threat | Before (INT) | After (Hybrid) | -|--------|-------------|----------------| -| ID Enumeration | ❌ Vulnerable (`/api/users/1,2,3...`) | ✅ Opaque UUID | -| Record Count Leak | ❌ `id=500` reveals ~500 records | ✅ UUID reveals nothing | -| BOLA Attack Surface | ❌ Predictable IDs | ✅ 2^122 possible values | -| Cross-System Collision | ❌ Possible | ✅ Globally unique | +| Threat | Before (INT) | After (Hybrid) | +| ---------------------- | ------------------------------------- | ------------------------ | +| ID Enumeration | ❌ Vulnerable (`/api/users/1,2,3...`) | ✅ Opaque UUID | +| Record Count Leak | ❌ `id=500` reveals ~500 records | ✅ UUID reveals nothing | +| BOLA Attack Surface | ❌ Predictable IDs | ✅ 2^122 possible values | +| Cross-System Collision | ❌ Possible | ✅ Globally unique | --- ## Compatibility with Existing ADRs -| ADR | Impact | Notes | -|-----|--------|-------| -| ADR-002 (Doc Numbering) | ✅ None | Document numbers (VARCHAR) are business identifiers, unaffected | -| ADR-005 (Tech Stack) | ✅ Compatible | uuid npm package + MariaDB native UUID type | -| ADR-006 (Redis Caching) | ⚠️ Minor | Cache keys should use UUID instead of INT for public-facing data | -| ADR-009 (DB Migration) | ✅ Compatible | ADD COLUMN is a safe, non-destructive migration | -| ADR-016 (Security) | ✅ Enhanced | Strengthens OWASP BOLA defense | -| ADR-017 (Ollama Migration) | ✅ None | Migration tables are temporary, excluded from UUID scope | +| ADR | Impact | Notes | +| -------------------------- | ------------- | ---------------------------------------------------------------- | +| ADR-002 (Doc Numbering) | ✅ None | Document numbers (VARCHAR) are business identifiers, unaffected | +| ADR-005 (Tech Stack) | ✅ Compatible | uuid npm package + MariaDB native UUID type | +| ADR-006 (Redis Caching) | ⚠️ Minor | Cache keys should use UUID instead of INT for public-facing data | +| ADR-009 (DB Migration) | ✅ Compatible | ADD COLUMN is a safe, non-destructive migration | +| ADR-016 (Security) | ✅ Enhanced | Strengthens OWASP BOLA defense | +| ADR-017 (Ollama Migration) | ✅ None | Migration tables are temporary, excluded from UUID scope | --- ## Transition Strategy ### Phase 1: Database (Schema Change) + - เพิ่ม `uuid UUID` column (MariaDB native type) กับ UNIQUE INDEX ใน 14 ตาราง - Existing rows ได้รับ UUID อัตโนมัติจาก DB DEFAULT ### Phase 2: Backend (Dual-Mode) + - เพิ่ม `uuid` field ใน TypeORM Entities - สร้าง `BaseUuidEntity` class - API รับได้ทั้ง INT และ UUID ผ่าน `FindByIdOrUuid` pattern - API Response รวม UUID เป็น `id` field ### Phase 3: Frontend (Gradual Migration) + - Frontend เปลี่ยนจากใช้ `id` (INT) เป็น `id` (UUID) ใน API response - URL parameters เปลี่ยนเป็น UUID - ไม่ต้อง Big-Bang migration — ค่อยๆ เปลี่ยนทีละ Module ### Phase 4: Cleanup + - ลบ INT ID จาก API Response (DTO) - ลบ INT-based route handlers - Update API Documentation @@ -464,15 +475,15 @@ WHERE c.uuid = '019505a1-7c3e-7000-8000-abc123def456'; ## Final Assessment -| Area | Status | -|------|--------| -| Security | ✅ Eliminates ID enumeration | -| Performance | ✅ No impact on internal JOINs | -| Migration Risk | ✅ Low — ADD COLUMN only | -| Storage Impact | ✅ Negligible (~3.8 MB) | -| Backward Compatibility | ✅ Dual-mode transition | -| ADR Compliance | ✅ Compatible with all existing ADRs | +| Area | Status | +| ---------------------- | ------------------------------------ | +| Security | ✅ Eliminates ID enumeration | +| Performance | ✅ No impact on internal JOINs | +| Migration Risk | ✅ Low — ADD COLUMN only | +| Storage Impact | ✅ Negligible (~3.8 MB) | +| Backward Compatibility | ✅ Dual-mode transition | +| ADR Compliance | ✅ Compatible with all existing ADRs | --- -*สำหรับรายละเอียดการ Implement ดูที่ Implementation Plan ใน ADR-019-implementation-plan.md* +_สำหรับรายละเอียดการ Implement ดูที่ Implementation Plan ใน ADR-019-implementation-plan.md_ diff --git a/specs/06-Decision-Records/Patch 1.8.1.md b/specs/06-Decision-Records/Patch 1.8.1.md index 91c9625..f6c8a12 100644 --- a/specs/06-Decision-Records/Patch 1.8.1.md +++ b/specs/06-Decision-Records/Patch 1.8.1.md @@ -1,5 +1,4 @@ -สรุป Patch 1.8.1 ---- +## สรุป Patch 1.8.1 # 📘 1) Formal Spec — Version 1.8.1 @@ -14,9 +13,9 @@ Spec 1.8.1 แก้ความไม่สอดคล้องระหว่าง: -* 03-04-legacy-data-migration.md -* 03-05-n8n-migration-setup-guide.md -* ADR-017-ollama-data-migration.md +- 03-04-legacy-data-migration.md +- 03-05-n8n-migration-setup-guide.md +- ADR-017-ollama-data-migration.md และกำหนด Production Boundary ที่ชัดเจน @@ -26,33 +25,33 @@ Spec 1.8.1 แก้ความไม่สอดคล้องระหว่ ### Infrastructure Layout -| Component | Host | Responsibility | -| ------------------ | ------------- | -------------- | -| DMS Frontend | QNAP | Production UI | -| DMS Backend | QNAP | Core API | -| MariaDB | QNAP | Authoritative DB | -| Redis | QNAP | Cache / BullMQ | -| Elasticsearch | QNAP | Full-text Search | -| Nginx Proxy Manager| QNAP | Public ingress / SSL | -| n8n + n8n-db | QNAP | Automation engine | -| Tika | QNAP | OCR / PDF extraction | -| Gitea | QNAP | Git + CI/CD | -| RocketChat | QNAP | Team communication | -| Grafana | ASUSTOR | Metrics dashboard | -| Prometheus | ASUSTOR | Metrics collection | -| Loki | ASUSTOR | Log aggregation | -| Promtail | ASUSTOR | Log shipper | -| uptime-kuma | ASUSTOR | Service availability | -| Gitea Runner | ASUSTOR | CI/CD build agent | -| Docker Registry | ASUSTOR | Image storage | -| Cloudflared | ASUSTOR | Tunnel / remote access | -| Ollama | Admin Desktop | AI processing only (i9-9900K, RTX 2060 SUPER 8GB) | +| Component | Host | Responsibility | +| ------------------- | ------------- | ------------------------------------------------- | +| DMS Frontend | QNAP | Production UI | +| DMS Backend | QNAP | Core API | +| MariaDB | QNAP | Authoritative DB | +| Redis | QNAP | Cache / BullMQ | +| Elasticsearch | QNAP | Full-text Search | +| Nginx Proxy Manager | QNAP | Public ingress / SSL | +| n8n + n8n-db | QNAP | Automation engine | +| Tika | QNAP | OCR / PDF extraction | +| Gitea | QNAP | Git + CI/CD | +| RocketChat | QNAP | Team communication | +| Grafana | ASUSTOR | Metrics dashboard | +| Prometheus | ASUSTOR | Metrics collection | +| Loki | ASUSTOR | Log aggregation | +| Promtail | ASUSTOR | Log shipper | +| uptime-kuma | ASUSTOR | Service availability | +| Gitea Runner | ASUSTOR | CI/CD build agent | +| Docker Registry | ASUSTOR | Image storage | +| Cloudflared | ASUSTOR | Tunnel / remote access | +| Ollama | Admin Desktop | AI processing only (i9-9900K, RTX 2060 SUPER 8GB) | **Constraints:** -* Ollama MUST NOT run on QNAP (production server) -* AI containers MUST NOT access production DB directly -* n8n calls Ollama via internal VLAN HTTP only +- Ollama MUST NOT run on QNAP (production server) +- AI containers MUST NOT access production DB directly +- n8n calls Ollama via internal VLAN HTTP only --- @@ -88,10 +87,10 @@ Migration MUST fail if required fields invalid. Automation must: -* Check existence by rfa_number -* Validate file hash -* UPDATE instead of INSERT if exists -* Prevent duplicate revision chain +- Check existence by rfa_number +- Validate file hash +- UPDATE instead of INSERT if exists +- Prevent duplicate revision chain --- @@ -171,10 +170,10 @@ No DB commit until validation approved. AI-based migration using Ollama introduces: -* DB corruption risk -* Hallucinated metadata -* Unauthorized modification -* Privilege escalation risk +- DB corruption risk +- Hallucinated metadata +- Unauthorized modification +- Privilege escalation risk Production DMS must remain authoritative. @@ -186,11 +185,11 @@ Production DMS must remain authoritative. Ollama must: -* Run on **Admin Desktop only** (NOT on QNAP) -* Have NO DB credentials -* Have NO write access to uploads -* Access only `/staging_ai` -* Output JSON only +- Run on **Admin Desktop only** (NOT on QNAP) +- Have NO DB credentials +- Have NO write access to uploads +- Access only `/staging_ai` +- Output JSON only --- @@ -212,9 +211,9 @@ AI never writes directly. All writes must go through: -* Authenticated DMS API -* RBAC enforced -* Audit log recorded +- Authenticated DMS API +- RBAC enforced +- Audit log recorded --- @@ -222,10 +221,10 @@ All writes must go through: AI output must: -* Match schema -* Pass validation script -* Fail on missing required fields -* Reject unknown users +- Match schema +- Pass validation script +- Fail on missing required fields +- Reject unknown users --- @@ -244,14 +243,14 @@ AI output must: Pros: -* Production safe -* Predictable migration -* Audit trail preserved +- Production safe +- Predictable migration +- Audit trail preserved Cons: -* Slightly slower pipeline -* Requires validation layer +- Slightly slower pipeline +- Requires validation layer --- @@ -288,7 +287,7 @@ Cons: Batch size recommendation: -* 20–50 RFAs per batch +- 20–50 RFAs per batch Process: @@ -330,11 +329,11 @@ When all batches pass: Monitor: -* DB errors -* Duplicate insert -* Missing files -* AI extraction errors -* API error rate +- DB errors +- Duplicate insert +- Missing files +- AI extraction errors +- API error rate If anomaly >5% → trigger rollback plan. @@ -368,11 +367,11 @@ Target RTO: < 2 hours System may go live only if: -* All dry-run tests pass -* 100% required fields valid -* 0 duplicate RFA -* Sample QA pass >95% -* Backup verified +- All dry-run tests pass +- 100% required fields valid +- 0 duplicate RFA +- Sample QA pass >95% +- Backup verified --- diff --git a/specs/06-Decision-Records/README.md b/specs/06-Decision-Records/README.md index cfcbf78..b43b357 100644 --- a/specs/06-Decision-Records/README.md +++ b/specs/06-Decision-Records/README.md @@ -28,42 +28,42 @@ Architecture Decision Records (ADRs) เป็นเอกสารที่บ ### Core Architecture Decisions -| ADR | Title | Status | Date | Summary | -| --------------------------------------------------- | --------------------------- | ---------- | ---------- | ------------------------------------------------------------------------- | +| ADR | Title | Status | Date | Summary | +| --------------------------------------------------- | --------------------------- | ----------- | ---------- | ---------------------------------------------------------------------------- | | [ADR-001](./ADR-001-unified-workflow-engine.md) | Unified Workflow Engine | ✅ Accepted | 2026-02-24 | ใช้ DSL-based Workflow Engine สำหรับ Correspondences, RFAs, และ Circulations | -| [ADR-002](./ADR-002-document-numbering-strategy.md) | Document Numbering Strategy | ✅ Accepted | 2026-02-24 | Double-lock mechanism (Redis + DB Optimistic Lock) สำหรับเลขที่เอกสาร | +| [ADR-002](./ADR-002-document-numbering-strategy.md) | Document Numbering Strategy | ✅ Accepted | 2026-02-24 | Double-lock mechanism (Redis + DB Optimistic Lock) สำหรับเลขที่เอกสาร | ### Security & Access Control -| ADR | Title | Status | Date | Summary | -| ----------------------------------------------- | ---------------------------------- | ---------- | ---------- | -------------------------------------------- | +| ADR | Title | Status | Date | Summary | +| ----------------------------------------------- | ---------------------------------- | ----------- | ---------- | -------------------------------------------- | | [ADR-016](./ADR-016-security-authentication.md) | Security & Authentication Strategy | ✅ Accepted | 2026-02-24 | JWT + bcrypt + OWASP Security Best Practices | ### Technology & Infrastructure -| ADR | Title | Status | Date | Summary | -| --------------------------------------------------- | ------------------------------------ | -------------------- | ---------- | ------------------------------------------------------------ | +| ADR | Title | Status | Date | Summary | +| --------------------------------------------------- | ------------------------------------ | --------------------- | ---------- | --------------------------------------------------------------- | | [ADR-005](./ADR-005-technology-stack.md) | Technology Stack Selection | ✅ Accepted | 2026-02-24 | Full Stack TypeScript: NestJS 11 + Next.js 16 + MariaDB + Redis | -| [ADR-006](./ADR-006-redis-caching-strategy.md) | Redis Usage & Caching Strategy | ✅ Accepted | 2026-02-24 | Redis สำหรับ Distributed Lock, Cache, Queue, และ Rate Limiting | -| [ADR-009](./ADR-009-database-migration-strategy.md) | Database Migration & Deployment | ✅ Accepted (Pending) | 2026-02-24 | TypeORM Migrations พร้อม Blue-Green Deployment | -| [ADR-015](./ADR-015-deployment-infrastructure.md) | Deployment & Infrastructure Strategy | ✅ Accepted | 2026-02-24 | Docker Compose with Blue-Green Deployment on QNAP | +| [ADR-006](./ADR-006-redis-caching-strategy.md) | Redis Usage & Caching Strategy | ✅ Accepted | 2026-02-24 | Redis สำหรับ Distributed Lock, Cache, Queue, และ Rate Limiting | +| [ADR-009](./ADR-009-database-migration-strategy.md) | Database Migration & Deployment | ✅ Accepted (Pending) | 2026-02-24 | TypeORM Migrations พร้อม Blue-Green Deployment | +| [ADR-015](./ADR-015-deployment-infrastructure.md) | Deployment & Infrastructure Strategy | ✅ Accepted | 2026-02-24 | Docker Compose with Blue-Green Deployment on QNAP | ### API & Integration -| ADR | Title | Status | Date | Summary | -| --------------------------------------------------- | ----------------------------- | --------------------------- | ---------- | --------------------------------------------------------------------------- | +| ADR | Title | Status | Date | Summary | +| --------------------------------------------------- | ----------------------------- | ---------------------------- | ---------- | ----------------------------------------------------------------------------- | | [ADR-008](./ADR-008-email-notification-strategy.md) | Email & Notification Strategy | ✅ Accepted (Pending Review) | 2026-02-24 | BullMQ + Redis Queue สำหรับ Multi-channel Notifications (Email, LINE, In-app) | ### Observability -| ADR | Title | Status | Date | Summary | -| --------------------------------------------------- | ----------------------------- | -------------------- | ---------- | ------------------------------------------------------------ | +| ADR | Title | Status | Date | Summary | +| --------------------------------------------------- | ----------------------------- | --------------------- | ---------- | ------------------------------------------------------------- | | [ADR-010](./ADR-010-logging-monitoring-strategy.md) | Logging & Monitoring Strategy | ✅ Accepted (Pending) | 2026-02-24 | Winston Structured Logging พร้อม Future ELK Stack Integration | ### Frontend Architecture -| ADR | Title | Status | Date | Summary | -| ------------------------------------------------ | -------------------------------- | ---------- | ---------- | ----------------------------------------------------- | +| ADR | Title | Status | Date | Summary | +| ------------------------------------------------ | -------------------------------- | ----------- | ---------- | ----------------------------------------------------- | | [ADR-011](./ADR-011-nextjs-app-router.md) | Next.js App Router & Routing | ✅ Accepted | 2025-12-01 | App Router with Server Components and Nested Layouts | | [ADR-012](./ADR-012-ui-component-library.md) | UI Component Library (Shadcn/UI) | ✅ Accepted | 2026-02-24 | Shadcn/UI + Tailwind CSS for Full Component Ownership | | [ADR-013](./ADR-013-form-handling-validation.md) | Form Handling & Validation | ✅ Accepted | 2026-02-24 | React Hook Form + Zod for Type-Safe Forms | @@ -71,9 +71,9 @@ Architecture Decision Records (ADRs) เป็นเอกสารที่บ ### Data & Identity -| ADR | Title | Status | Date | Summary | -| ------------------------------------------------------------ | ---------------------------- | ---------- | ---------- | -------------------------------------------------------- | -| [ADR-019](./ADR-019-hybrid-identifier-strategy.md) | Hybrid Identifier Strategy | ✅ Accepted | 2026-03-11 | INT PK (internal) + UUIDv7 (public API) on 14 tables | +| ADR | Title | Status | Date | Summary | +| -------------------------------------------------- | -------------------------- | ----------- | ---------- | ---------------------------------------------------- | +| [ADR-019](./ADR-019-hybrid-identifier-strategy.md) | Hybrid Identifier Strategy | ✅ Accepted | 2026-03-11 | INT PK (internal) + UUIDv7 (public API) on 14 tables | --- diff --git a/specs/99-archives/01-02-architecture.md b/specs/99-archives/01-02-architecture.md index 62c17fe..4c98f7b 100644 --- a/specs/99-archives/01-02-architecture.md +++ b/specs/99-archives/01-02-architecture.md @@ -40,294 +40,281 @@ specs/01-objectives.md ## **2.2 Netwrok Configuration** **VLAN Networks** -| VLAN ID | Name | Purpose | Gateway/Subnet | DHCP | IP Range | DNS | Lease Time | ARP Detection | IGMP Snooping | MLD Snooping | Notes | +| VLAN ID | Name | Purpose | Gateway/Subnet | DHCP | IP Range | DNS | Lease Time | ARP Detection | IGMP Snooping | MLD Snooping | Notes | | ------- | ------ | --------- | --------------- | ---- | ------------------ | ------- | ---------- | ------------- | ------------- | ------------ | --------------- | -| 10 | SERVER | Interface | 192.168.10.1/24 | No | - | Custom | - | - | - | - | Static servers | -| 20 | MGMT | Interface | 192.168.20.1/24 | No | - | Custom | - | Enable | Enable | - | Management only | -| 30 | USER | Interface | 192.168.30.1/24 | Yes | 192.168.30.10-254 | Auto | 7 Days | - | Enable | - | User devices | -| 40 | CCTV | Interface | 192.168.40.1/24 | Yes | 192.168.40.100-150 | Auto | 7 Days | - | Enable | - | CCTV & NVR | -| 50 | VOICE | Interface | 192.168.50.1/24 | Yes | 192.168.50.201-250 | Auto | 7 Days | - | - | - | IP Phones | -| 60 | DMZ | Interface | 192.168.60.1/24 | No | - | 1.1.1.1 | - | - | - | - | Public services | -| 70 | GUEST | Interface | 192.168.70.1/24 | Yes | 192.168.70.200-250 | Auto | 1 Day | - | - | - | Guest | - +| 10 | SERVER | Interface | 192.168.10.1/24 | No | - | Custom | - | - | - | - | Static servers | +| 20 | MGMT | Interface | 192.168.20.1/24 | No | - | Custom | - | Enable | Enable | - | Management only | +| 30 | USER | Interface | 192.168.30.1/24 | Yes | 192.168.30.10-254 | Auto | 7 Days | - | Enable | - | User devices | +| 40 | CCTV | Interface | 192.168.40.1/24 | Yes | 192.168.40.100-150 | Auto | 7 Days | - | Enable | - | CCTV & NVR | +| 50 | VOICE | Interface | 192.168.50.1/24 | Yes | 192.168.50.201-250 | Auto | 7 Days | - | - | - | IP Phones | +| 60 | DMZ | Interface | 192.168.60.1/24 | No | - | 1.1.1.1 | - | - | - | - | Public services | +| 70 | GUEST | Interface | 192.168.70.1/24 | Yes | 192.168.70.200-250 | Auto | 1 Day | - | - | - | Guest | **Switch Profiles** -| Profile Name | Native Network | Tagged Networks | Untagged Networks | Voice Network | Loopback Control | Usage | +| Profile Name | Native Network | Tagged Networks | Untagged Networks | Voice Network | Loopback Control | Usage | | ---------------- | -------------- | --------------------- | ----------------- | ------------- | ---------------- | ----------------------- | -| 01_CORE_TRUNK | MGMT (20) | 10,30,40,50,60,70 | MGMT (20) | - | Spanning Tree | Router & switch uplinks | -| 02_MGMT_ONLY | MGMT (20) | MGMT (20) | - | - | Spanning Tree | Management only | -| 03_SERVER_ACCESS | SERVER (10) | MGMT (20) | SERVER (10) | - | Spanning Tree | QNAP / ASUSTOR | -| 04_CCTV_ACCESS | CCTV (40) | - | CCTV (40) | - | Spanning Tree | CCTV cameras | -| 05_USER_ACCESS | USER (30) | - | USER (30) | - | Spanning Tree | PC / Printer | -| 06_AP_TRUNK | MGMT (20) | USER (30), GUEST (70) | MGMT (20) | - | Spanning Tree | EAP610 Access Points | -| 07_VOICE_ACCESS | USER (30) | VOICE (50) | USER (30) | VOICE (50) | Spanning Tree | IP Phones | - +| 01_CORE_TRUNK | MGMT (20) | 10,30,40,50,60,70 | MGMT (20) | - | Spanning Tree | Router & switch uplinks | +| 02_MGMT_ONLY | MGMT (20) | MGMT (20) | - | - | Spanning Tree | Management only | +| 03_SERVER_ACCESS | SERVER (10) | MGMT (20) | SERVER (10) | - | Spanning Tree | QNAP / ASUSTOR | +| 04_CCTV_ACCESS | CCTV (40) | - | CCTV (40) | - | Spanning Tree | CCTV cameras | +| 05_USER_ACCESS | USER (30) | - | USER (30) | - | Spanning Tree | PC / Printer | +| 06_AP_TRUNK | MGMT (20) | USER (30), GUEST (70) | MGMT (20) | - | Spanning Tree | EAP610 Access Points | +| 07_VOICE_ACCESS | USER (30) | VOICE (50) | USER (30) | VOICE (50) | Spanning Tree | IP Phones | **ER7206 Port Mapping** -| Port | Connected Device | Port | Description | +| Port | Connected Device | Port | Description | | ---- | ---------------- | ------------- | ----------- | -| 1 | - | - | - | -| 2 | WAN | - | Internet | -| 3 | SG2428P | PVID MGMT(20) | Core Switch | -| 4 | - | - | - | -| 5 | - | - | - | -| 6 | - | - | - | +| 1 | - | - | - | +| 2 | WAN | - | Internet | +| 3 | SG2428P | PVID MGMT(20) | Core Switch | +| 4 | - | - | - | +| 5 | - | - | - | +| 6 | - | - | - | **AMPCOM Port Aggregate Setting** | Aggregate Group ID | Type | Member port | Aggregated Port | | ------------------ | ---- | ----------- | --------------- | -| Trunk1 | LACP | 3,4 | 3,4 | -| Trunk2 | LACP | 5,6 | 5,6 | - +| Trunk1 | LACP | 3,4 | 3,4 | +| Trunk2 | LACP | 5,6 | 5,6 | **AMPCOM Port VLAN Mapping** -| Port | Connected Device | Port vlan type | Access VLAN | Native VLAN | Trunk vlan | +| Port | Connected Device | Port vlan type | Access VLAN | Native VLAN | Trunk vlan | | ------ | ---------------- | -------------- | ----------- | ----------- | -------------------- | -| 1 | SG2428P | Trunk | - | 20 | 10,20,30,40,50,60,70 | -| 2 | - | Trunk | - | 20 | 10,20,30,40,50,60,70 | -| 7 | - | Access | 20 | - | - | -| 8 | Admin Desktop | Access | 20 | - | - | -| Trunk1 | QNAP | Trunk | - | 10 | 10,20,30,40,50,60,70 | -| Trunk2 | ASUSTOR | Trunk | - | 10 | 10,20,30,40,50,60,70 | - +| 1 | SG2428P | Trunk | - | 20 | 10,20,30,40,50,60,70 | +| 2 | - | Trunk | - | 20 | 10,20,30,40,50,60,70 | +| 7 | - | Access | 20 | - | - | +| 8 | Admin Desktop | Access | 20 | - | - | +| Trunk1 | QNAP | Trunk | - | 10 | 10,20,30,40,50,60,70 | +| Trunk2 | ASUSTOR | Trunk | - | 10 | 10,20,30,40,50,60,70 | **NAS NIC Bonding Configuration** -| Device | Bonding Mode | Member Ports | VLAN Mode | Tagged VLAN | IP Address | Gateway | Notes | +| Device | Bonding Mode | Member Ports | VLAN Mode | Tagged VLAN | IP Address | Gateway | Notes | | ------- | ------------------- | ------------ | --------- | ----------- | --------------- | ------------ | ---------------------- | -| QNAP | IEEE 802.3ad (LACP) | Adapter 1, 2 | Untagged | 10 (SERVER) | 192.168.10.8/24 | 192.168.10.1 | Primary NAS for DMS | -| ASUSTOR | IEEE 802.3ad (LACP) | Port 1, 2 | Untagged | 10 (SERVER) | 192.168.10.9/24 | 192.168.10.1 | Backup / Secondary NAS | +| QNAP | IEEE 802.3ad (LACP) | Adapter 1, 2 | Untagged | 10 (SERVER) | 192.168.10.8/24 | 192.168.10.1 | Primary NAS for DMS | +| ASUSTOR | IEEE 802.3ad (LACP) | Port 1, 2 | Untagged | 10 (SERVER) | 192.168.10.9/24 | 192.168.10.1 | Backup / Secondary NAS | > **หมายเหตุ**: NAS ทั้งสองตัวใช้ LACP bonding เพื่อเพิ่ม bandwidth และ redundancy โดยต้อง config ให้ตรงกับ AMPCOM Switch (Trunk1) - **SG2428P Port Mapping** -| Port | Connected Device | Switch Profile | Description | +| Port | Connected Device | Switch Profile | Description | | ---- | ------------------------- | -------------------- | ------------- | -| 1 | ER7206 | 01_CORE_TRUNK | Internet | -| 2 | OC200 | 01_CORE_TRUNK | Controller | -| 3 | Ampcom 2.5G Switch Port 1 | LAG1 (01_CORE_TRUNK) | Uplink | -| 4 | - | LAG1 (01_CORE_TRUNK) | Reserved | -| 5 | EAP610-01 | 06_AP_TRUNK | Access Point | -| 6 | EAP610-02 | 06_AP_TRUNK | Access Point | -| 7 | EAP610-03 | 06_AP_TRUNK | Access Point | -| 8 | EAP610-04 | 06_AP_TRUNK | Access Point | -| 9 | EAP610-05 | 06_AP_TRUNK | Access Point | -| 10 | EAP610-06 | 06_AP_TRUNK | Access Point | -| 11 | EAP610-07 | 06_AP_TRUNK | Access Point | -| 12 | EAP610-08 | 06_AP_TRUNK | Access Point | -| 13 | EAP610-09 | 06_AP_TRUNK | Access Point | -| 14 | EAP610-10 | 06_AP_TRUNK | Access Point | -| 15 | EAP610-11 | 06_AP_TRUNK | Access Point | -| 16 | EAP610-12 | 06_AP_TRUNK | Access Point | -| 17 | EAP610-13 | 06_AP_TRUNK | Access Point | -| 18 | EAP610-14 | 06_AP_TRUNK | Access Point | -| 19 | EAP610-15 | 06_AP_TRUNK | Access Point | -| 20 | EAP610-16 | 06_AP_TRUNK | Access Point | -| 21 | Reserved | 01_CORE_TRUNK | | -| 22 | Reserved | 01_CORE_TRUNK | | -| 23 | Printer | 05_USER_ACCESS | Printer | -| 24 | ES205G | 01_CORE_TRUNK | Management PC | -| 25 | TL-SL1226P | 01_CORE_TRUNK | Uplink | -| 26 | SG1210P | 01_CORE_TRUNK | Uplink | -| 27 | Reserved | 01_CORE_TRUNK | | -| 28 | Reserved | 01_CORE_TRUNK | | - +| 1 | ER7206 | 01_CORE_TRUNK | Internet | +| 2 | OC200 | 01_CORE_TRUNK | Controller | +| 3 | Ampcom 2.5G Switch Port 1 | LAG1 (01_CORE_TRUNK) | Uplink | +| 4 | - | LAG1 (01_CORE_TRUNK) | Reserved | +| 5 | EAP610-01 | 06_AP_TRUNK | Access Point | +| 6 | EAP610-02 | 06_AP_TRUNK | Access Point | +| 7 | EAP610-03 | 06_AP_TRUNK | Access Point | +| 8 | EAP610-04 | 06_AP_TRUNK | Access Point | +| 9 | EAP610-05 | 06_AP_TRUNK | Access Point | +| 10 | EAP610-06 | 06_AP_TRUNK | Access Point | +| 11 | EAP610-07 | 06_AP_TRUNK | Access Point | +| 12 | EAP610-08 | 06_AP_TRUNK | Access Point | +| 13 | EAP610-09 | 06_AP_TRUNK | Access Point | +| 14 | EAP610-10 | 06_AP_TRUNK | Access Point | +| 15 | EAP610-11 | 06_AP_TRUNK | Access Point | +| 16 | EAP610-12 | 06_AP_TRUNK | Access Point | +| 17 | EAP610-13 | 06_AP_TRUNK | Access Point | +| 18 | EAP610-14 | 06_AP_TRUNK | Access Point | +| 19 | EAP610-15 | 06_AP_TRUNK | Access Point | +| 20 | EAP610-16 | 06_AP_TRUNK | Access Point | +| 21 | Reserved | 01_CORE_TRUNK | | +| 22 | Reserved | 01_CORE_TRUNK | | +| 23 | Printer | 05_USER_ACCESS | Printer | +| 24 | ES205G | 01_CORE_TRUNK | Management PC | +| 25 | TL-SL1226P | 01_CORE_TRUNK | Uplink | +| 26 | SG1210P | 01_CORE_TRUNK | Uplink | +| 27 | Reserved | 01_CORE_TRUNK | | +| 28 | Reserved | 01_CORE_TRUNK | | **ES205G Port Mapping (Admin Switch)** -| Port | Connected Device | VLAN | Description | +| Port | Connected Device | VLAN | Description | | ---- | ---------------- | ----------- | ----------- | -| 1 | SG2428P Port 24 | Trunk (All) | Uplink | -| 2 | Admin Desktop | MGMT (20) | Admin PC | -| 3 | Reserved | MGMT (20) | | -| 4 | Reserved | MGMT (20) | | -| 5 | Reserved | MGMT (20) | | +| 1 | SG2428P Port 24 | Trunk (All) | Uplink | +| 2 | Admin Desktop | MGMT (20) | Admin PC | +| 3 | Reserved | MGMT (20) | | +| 4 | Reserved | MGMT (20) | | +| 5 | Reserved | MGMT (20) | | > **หมายเหตุ**: ES205G เป็น Unmanaged Switch ไม่รองรับ VLAN tagging ดังนั้นทุก port จะอยู่ใน Native VLAN (20) ของ uplink - **TL-SL1226P Port Mapping (CCTV Switch)** -| Port | Connected Device | PoE | VLAN | Description | +| Port | Connected Device | PoE | VLAN | Description | | ---- | ---------------- | ---- | --------- | ----------- | -| 1 | Camera-01 | PoE+ | CCTV (40) | CCTV Camera | -| 2 | Camera-02 | PoE+ | CCTV (40) | CCTV Camera | -| 3 | Camera-03 | PoE+ | CCTV (40) | CCTV Camera | -| 4 | Camera-04 | PoE+ | CCTV (40) | CCTV Camera | -| 5 | Camera-05 | PoE+ | CCTV (40) | CCTV Camera | -| 6 | Camera-06 | PoE+ | CCTV (40) | CCTV Camera | -| 7-23 | Reserved | PoE+ | CCTV (40) | | -| 24 | HikVision NVR | - | CCTV (40) | NVR | -| 25 | SG2428P Port 25 | - | Trunk | SFP Uplink | -| 26 | Reserved | - | Trunk | SFP | - +| 1 | Camera-01 | PoE+ | CCTV (40) | CCTV Camera | +| 2 | Camera-02 | PoE+ | CCTV (40) | CCTV Camera | +| 3 | Camera-03 | PoE+ | CCTV (40) | CCTV Camera | +| 4 | Camera-04 | PoE+ | CCTV (40) | CCTV Camera | +| 5 | Camera-05 | PoE+ | CCTV (40) | CCTV Camera | +| 6 | Camera-06 | PoE+ | CCTV (40) | CCTV Camera | +| 7-23 | Reserved | PoE+ | CCTV (40) | | +| 24 | HikVision NVR | - | CCTV (40) | NVR | +| 25 | SG2428P Port 25 | - | Trunk | SFP Uplink | +| 26 | Reserved | - | Trunk | SFP | **SG1210P Port Mapping (IP Phone Switch)** -| Port | Connected Device | PoE | Data VLAN | Voice VLAN | Description | +| Port | Connected Device | PoE | Data VLAN | Voice VLAN | Description | | ------- | ---------------- | ---- | --------- | ---------- | ----------- | -| 1 | IP Phone-01 | PoE+ | USER (30) | VOICE (50) | IP Phone | -| 2 | IP Phone-02 | PoE+ | USER (30) | VOICE (50) | IP Phone | -| 3 | IP Phone-03 | PoE+ | USER (30) | VOICE (50) | IP Phone | -| 4 | IP Phone-04 | PoE+ | USER (30) | VOICE (50) | IP Phone | -| 5 | IP Phone-05 | PoE+ | USER (30) | VOICE (50) | IP Phone | -| 6 | IP Phone-06 | PoE+ | USER (30) | VOICE (50) | IP Phone | -| 7 | IP Phone-07 | PoE+ | USER (30) | VOICE (50) | IP Phone | -| 8 | IP Phone-08 | PoE+ | USER (30) | VOICE (50) | IP Phone | -| Uplink1 | Reserved | - | Trunk | - | RJ45 Uplink | -| Uplink2 | SG2428P Port 26 | - | Trunk | - | SFP Uplink | +| 1 | IP Phone-01 | PoE+ | USER (30) | VOICE (50) | IP Phone | +| 2 | IP Phone-02 | PoE+ | USER (30) | VOICE (50) | IP Phone | +| 3 | IP Phone-03 | PoE+ | USER (30) | VOICE (50) | IP Phone | +| 4 | IP Phone-04 | PoE+ | USER (30) | VOICE (50) | IP Phone | +| 5 | IP Phone-05 | PoE+ | USER (30) | VOICE (50) | IP Phone | +| 6 | IP Phone-06 | PoE+ | USER (30) | VOICE (50) | IP Phone | +| 7 | IP Phone-07 | PoE+ | USER (30) | VOICE (50) | IP Phone | +| 8 | IP Phone-08 | PoE+ | USER (30) | VOICE (50) | IP Phone | +| Uplink1 | Reserved | - | Trunk | - | RJ45 Uplink | +| Uplink2 | SG2428P Port 26 | - | Trunk | - | SFP Uplink | > **หมายเหตุ**: SG1210P รองรับ Voice VLAN ทำให้ IP Phone ใช้ VLAN 50 สำหรับ voice traffic และ passthrough VLAN 30 สำหรับ PC ที่ต่อผ่าน phone - **Static IP Allocation** -| VLAN | Device | IP Address | MAC Address | Notes | +| VLAN | Device | IP Address | MAC Address | Notes | | ---------- | --------------- | ------------------ | ----------- | ---------------- | -| SERVER(10) | QNAP | 192.168.10.8 | - | Primary NAS | -| SERVER(10) | ASUSTOR | 192.168.10.9 | - | Backup NAS | -| SERVER(10) | Docker Host | 192.168.10.10 | - | Containers | -| MGMT(20) | ER7206 | 192.168.20.1 | - | Gateway/Router | -| MGMT(20) | SG2428P | 192.168.20.2 | - | Core Switch | -| MGMT(20) | AMPCOM | 192.168.20.3 | - | Server Switch | -| MGMT(20) | TL-SL1226P | 192.168.20.4 | - | CCTV Switch | -| MGMT(20) | SG1210P | 192.168.20.5 | - | Phone Switch | -| MGMT(20) | OC200 | 192.168.20.250 | - | Omada Controller | -| MGMT(20) | Admin Desktop | 192.168.20.100 | - | Admin PC | -| USER(30) | Printer | 192.168.30.222 | - | Kyocera CS3554ci | -| CCTV(40) | NVR | 192.168.40.100 | - | HikVision NVR | -| CCTV(40) | Camera-01 to 06 | 192.168.40.101-106 | - | CCTV Cameras | -| USER(30) | Admin Desktop | 192.168.30.100 | - | Admin PC (USER) | +| SERVER(10) | QNAP | 192.168.10.8 | - | Primary NAS | +| SERVER(10) | ASUSTOR | 192.168.10.9 | - | Backup NAS | +| SERVER(10) | Docker Host | 192.168.10.10 | - | Containers | +| MGMT(20) | ER7206 | 192.168.20.1 | - | Gateway/Router | +| MGMT(20) | SG2428P | 192.168.20.2 | - | Core Switch | +| MGMT(20) | AMPCOM | 192.168.20.3 | - | Server Switch | +| MGMT(20) | TL-SL1226P | 192.168.20.4 | - | CCTV Switch | +| MGMT(20) | SG1210P | 192.168.20.5 | - | Phone Switch | +| MGMT(20) | OC200 | 192.168.20.250 | - | Omada Controller | +| MGMT(20) | Admin Desktop | 192.168.20.100 | - | Admin PC | +| USER(30) | Printer | 192.168.30.222 | - | Kyocera CS3554ci | +| CCTV(40) | NVR | 192.168.40.100 | - | HikVision NVR | +| CCTV(40) | Camera-01 to 06 | 192.168.40.101-106 | - | CCTV Cameras | +| USER(30) | Admin Desktop | 192.168.30.100 | - | Admin PC (USER) | **2.8 DHCP Reservation (MAC Mapping)** **CCTV MAC Address Mapping (VLAN 40)** -| Device Name | IP Address | MAC Address | Port (Switch) | Notes | +| Device Name | IP Address | MAC Address | Port (Switch) | Notes | | ------------- | -------------- | ----------- | ------------- | ---------- | -| HikVision NVR | 192.168.40.100 | | Port 24 | Master NVR | -| Camera-01 | 192.168.40.101 | | Port 1 | | -| Camera-02 | 192.168.40.102 | | Port 2 | | -| Camera-03 | 192.168.40.103 | | Port 3 | | -| Camera-04 | 192.168.40.104 | | Port 4 | | -| Camera-05 | 192.168.40.105 | | Port 5 | | -| Camera-06 | 192.168.40.106 | | Port 6 | | +| HikVision NVR | 192.168.40.100 | | Port 24 | Master NVR | +| Camera-01 | 192.168.40.101 | | Port 1 | | +| Camera-02 | 192.168.40.102 | | Port 2 | | +| Camera-03 | 192.168.40.103 | | Port 3 | | +| Camera-04 | 192.168.40.104 | | Port 4 | | +| Camera-05 | 192.168.40.105 | | Port 5 | | +| Camera-06 | 192.168.40.106 | | Port 6 | | **IP Phone MAC Address Mapping (VLAN 50)** -| Device Name | IP Address | MAC Address | Port (Switch) | Notes | +| Device Name | IP Address | MAC Address | Port (Switch) | Notes | | ----------- | -------------- | ----------- | ------------- | ------- | -| IP Phone-01 | 192.168.50.201 | | Port 1 | Yealink | -| IP Phone-02 | 192.168.50.202 | | Port 2 | Yealink | -| IP Phone-03 | 192.168.50.203 | | Port 3 | Yealink | -| IP Phone-04 | 192.168.50.204 | | Port 4 | Yealink | -| IP Phone-05 | 192.168.50.205 | | Port 5 | Yealink | -| IP Phone-06 | 192.168.50.206 | | Port 6 | Yealink | -| IP Phone-07 | 192.168.50.207 | | Port 7 | Yealink | -| IP Phone-08 | 192.168.50.208 | | Port 8 | Yealink | - +| IP Phone-01 | 192.168.50.201 | | Port 1 | Yealink | +| IP Phone-02 | 192.168.50.202 | | Port 2 | Yealink | +| IP Phone-03 | 192.168.50.203 | | Port 3 | Yealink | +| IP Phone-04 | 192.168.50.204 | | Port 4 | Yealink | +| IP Phone-05 | 192.168.50.205 | | Port 5 | Yealink | +| IP Phone-06 | 192.168.50.206 | | Port 6 | Yealink | +| IP Phone-07 | 192.168.50.207 | | Port 7 | Yealink | +| IP Phone-08 | 192.168.50.208 | | Port 8 | Yealink | **Wireless SSID Mapping (OC200 Controller)** -| SSID Name | Band | VLAN | Security | Portal Auth | Notes | +| SSID Name | Band | VLAN | Security | Portal Auth | Notes | | --------- | ------- | ---------- | --------- | ----------- | ----------------------- | -| PSLCBP3 | 2.4G/5G | USER (30) | WPA2/WPA3 | No | Staff WiFi | -| GUEST | 2.4G/5G | GUEST (70) | WPA2 | Yes | Guest WiFi with Captive | +| PSLCBP3 | 2.4G/5G | USER (30) | WPA2/WPA3 | No | Staff WiFi | +| GUEST | 2.4G/5G | GUEST (70) | WPA2 | Yes | Guest WiFi with Captive | > **หมายเหตุ**: ทุก SSID broadcast ผ่าน EAP610 ทั้ง 16 ตัว โดยใช้ 06_AP_TRUNK profile ที่ tag VLAN 30 และ 70 - **Gateway ACL (ER7206 Firewall Rules)** -*Inter-VLAN Routing Policy* -| # | Name | Source | Destination | Service | Action | Log | Notes | +_Inter-VLAN Routing Policy_ +| # | Name | Source | Destination | Service | Action | Log | Notes | | --- | ----------------- | --------------- | ---------------- | -------------- | ------ | --- | --------------------------- | -| 1 | MGMT-to-ALL | VLAN20 (MGMT) | Any | Any | Allow | No | Admin full access | -| 2 | SERVER-to-ALL | VLAN10 (SERVER) | Any | Any | Allow | No | Servers outbound access | -| 3 | USER-to-SERVER | VLAN30 (USER) | VLAN10 (SERVER) | HTTP/HTTPS/SSH | Allow | No | Users access web apps | -| 4 | USER-to-DMZ | VLAN30 (USER) | VLAN60 (DMZ) | HTTP/HTTPS | Allow | No | Users access DMZ services | -| 5 | USER-to-MGMT | VLAN30 (USER) | VLAN20 (MGMT) | Any | Deny | Yes | Block users from management | -| 6 | USER-to-CCTV | VLAN30 (USER) | VLAN40 (CCTV) | Any | Deny | Yes | Isolate CCTV | -| 7 | USER-to-VOICE | VLAN30 (USER) | VLAN50 (VOICE) | Any | Deny | No | Isolate Voice | -| 8 | USER-to-GUEST | VLAN30 (USER) | VLAN70 (GUEST) | Any | Deny | No | Isolate Guest | -| 9 | CCTV-to-INTERNET | VLAN40 (CCTV) | WAN | HTTPS (443) | Allow | No | NVR cloud backup (optional) | -| 10 | CCTV-to-ALL | VLAN40 (CCTV) | Any (except WAN) | Any | Deny | Yes | CCTV isolated | -| 11 | VOICE-to-SIP | VLAN50 (VOICE) | SIP Server IP | SIP/RTP | Allow | No | Voice to SIP trunk | -| 12 | VOICE-to-ALL | VLAN50 (VOICE) | Any | Any | Deny | No | Voice isolated | -| 13 | DMZ-to-ALL | VLAN60 (DMZ) | Any (internal) | Any | Deny | Yes | DMZ cannot reach internal | -| 14 | GUEST-to-INTERNET | VLAN70 (GUEST) | WAN | HTTP/HTTPS/DNS | Allow | No | Guest internet only | -| 15 | GUEST-to-ALL | VLAN70 (GUEST) | Any (internal) | Any | Deny | Yes | Guest isolated | -| 99 | DEFAULT-DENY | Any | Any | Any | Deny | Yes | Catch-all deny | +| 1 | MGMT-to-ALL | VLAN20 (MGMT) | Any | Any | Allow | No | Admin full access | +| 2 | SERVER-to-ALL | VLAN10 (SERVER) | Any | Any | Allow | No | Servers outbound access | +| 3 | USER-to-SERVER | VLAN30 (USER) | VLAN10 (SERVER) | HTTP/HTTPS/SSH | Allow | No | Users access web apps | +| 4 | USER-to-DMZ | VLAN30 (USER) | VLAN60 (DMZ) | HTTP/HTTPS | Allow | No | Users access DMZ services | +| 5 | USER-to-MGMT | VLAN30 (USER) | VLAN20 (MGMT) | Any | Deny | Yes | Block users from management | +| 6 | USER-to-CCTV | VLAN30 (USER) | VLAN40 (CCTV) | Any | Deny | Yes | Isolate CCTV | +| 7 | USER-to-VOICE | VLAN30 (USER) | VLAN50 (VOICE) | Any | Deny | No | Isolate Voice | +| 8 | USER-to-GUEST | VLAN30 (USER) | VLAN70 (GUEST) | Any | Deny | No | Isolate Guest | +| 9 | CCTV-to-INTERNET | VLAN40 (CCTV) | WAN | HTTPS (443) | Allow | No | NVR cloud backup (optional) | +| 10 | CCTV-to-ALL | VLAN40 (CCTV) | Any (except WAN) | Any | Deny | Yes | CCTV isolated | +| 11 | VOICE-to-SIP | VLAN50 (VOICE) | SIP Server IP | SIP/RTP | Allow | No | Voice to SIP trunk | +| 12 | VOICE-to-ALL | VLAN50 (VOICE) | Any | Any | Deny | No | Voice isolated | +| 13 | DMZ-to-ALL | VLAN60 (DMZ) | Any (internal) | Any | Deny | Yes | DMZ cannot reach internal | +| 14 | GUEST-to-INTERNET | VLAN70 (GUEST) | WAN | HTTP/HTTPS/DNS | Allow | No | Guest internet only | +| 15 | GUEST-to-ALL | VLAN70 (GUEST) | Any (internal) | Any | Deny | Yes | Guest isolated | +| 99 | DEFAULT-DENY | Any | Any | Any | Deny | Yes | Catch-all deny | -*WAN Inbound Rules (Port Forwarding)* -| # | Name | WAN Port | Internal IP | Internal Port | Protocol | Notes | +_WAN Inbound Rules (Port Forwarding)_ +| # | Name | WAN Port | Internal IP | Internal Port | Protocol | Notes | | --- | --------- | -------- | ------------ | ------------- | -------- | ------------------- | -| 1 | HTTPS-NPM | 443 | 192.168.10.8 | 443 | TCP | Nginx Proxy Manager | -| 2 | HTTP-NPM | 80 | 192.168.10.8 | 80 | TCP | HTTP redirect | +| 1 | HTTPS-NPM | 443 | 192.168.10.8 | 443 | TCP | Nginx Proxy Manager | +| 2 | HTTP-NPM | 80 | 192.168.10.8 | 80 | TCP | HTTP redirect | > **หมายเหตุ**: ER7206 ใช้หลักการ Default Deny - Rules ประมวลผลจากบนลงล่าง - **Switch ACL (SG2428P Layer 2 Rules)** -*Port-Based Access Control* -| # | Name | Source Port | Source MAC/VLAN | Destination | Action | Notes | +_Port-Based Access Control_ +| # | Name | Source Port | Source MAC/VLAN | Destination | Action | Notes | | --- | --------------- | --------------- | --------------- | ------------------- | ------ | ------------------------ | -| 1 | CCTV-Isolation | Port 25 (CCTV) | VLAN 40 | VLAN 10,20,30 | Deny | CCTV cannot reach others | -| 2 | Guest-Isolation | Port 5-20 (APs) | VLAN 70 | VLAN 10,20,30,40,50 | Deny | Guest isolation | -| 3 | Voice-QoS | Port 26 (Phone) | VLAN 50 | Any | Allow | QoS priority DSCP EF | +| 1 | CCTV-Isolation | Port 25 (CCTV) | VLAN 40 | VLAN 10,20,30 | Deny | CCTV cannot reach others | +| 2 | Guest-Isolation | Port 5-20 (APs) | VLAN 70 | VLAN 10,20,30,40,50 | Deny | Guest isolation | +| 3 | Voice-QoS | Port 26 (Phone) | VLAN 50 | Any | Allow | QoS priority DSCP EF | -*Storm Control (per port)* -| Port Range | Broadcast | Multicast | Unknown Unicast | Notes | +_Storm Control (per port)_ +| Port Range | Broadcast | Multicast | Unknown Unicast | Notes | | ---------- | --------- | --------- | --------------- | ----------------------- | -| 1-28 | 10% | 10% | 10% | Prevent broadcast storm | +| 1-28 | 10% | 10% | 10% | Prevent broadcast storm | -*Spanning Tree Configuration* -| Setting | Value | Notes | +_Spanning Tree Configuration_ +| Setting | Value | Notes | | -------------------- | --------- | ------------------------------ | -| STP Mode | RSTP | Rapid Spanning Tree | -| Root Bridge Priority | 4096 | SG2428P as root | -| Port Fast | Port 5-24 | Edge ports (APs, endpoints) | -| BPDU Guard | Port 5-24 | Protect against rogue switches | +| STP Mode | RSTP | Rapid Spanning Tree | +| Root Bridge Priority | 4096 | SG2428P as root | +| Port Fast | Port 5-24 | Edge ports (APs, endpoints) | +| BPDU Guard | Port 5-24 | Protect against rogue switches | > **หมายเหตุ**: SG2428P เป็น L2+ switch, ACL ทำได้จำกัด ให้ใช้ ER7206 เป็น primary firewall - **EAP ACL (Omada Controller - Wireless Rules)** -*SSID: PSLCBP3 (Staff WiFi)* -| # | Name | Source | Destination | Service | Action | Schedule | Notes | +_SSID: PSLCBP3 (Staff WiFi)_ +| # | Name | Source | Destination | Service | Action | Schedule | Notes | | --- | ------------------- | ---------- | ---------------- | -------- | ------ | -------- | ----------------- | -| 1 | Allow-DNS | Any Client | 8.8.8.8, 1.1.1.1 | DNS (53) | Allow | Always | DNS resolution | -| 2 | Allow-Server | Any Client | 192.168.10.0/24 | Any | Allow | Always | Access to servers | -| 3 | Allow-Printer | Any Client | 192.168.30.222 | 9100,631 | Allow | Always | Print services | -| 4 | Allow-Internet | Any Client | WAN | Any | Allow | Always | Internet access | -| 5 | Block-MGMT | Any Client | 192.168.20.0/24 | Any | Deny | Always | No management | -| 6 | Block-CCTV | Any Client | 192.168.40.0/24 | Any | Deny | Always | No CCTV access | -| 7 | Block-Voice | Any Client | 192.168.50.0/24 | Any | Deny | Always | No Voice access | -| 8 | Block-Client2Client | Any Client | Any Client | Any | Deny | Always | Client isolation | +| 1 | Allow-DNS | Any Client | 8.8.8.8, 1.1.1.1 | DNS (53) | Allow | Always | DNS resolution | +| 2 | Allow-Server | Any Client | 192.168.10.0/24 | Any | Allow | Always | Access to servers | +| 3 | Allow-Printer | Any Client | 192.168.30.222 | 9100,631 | Allow | Always | Print services | +| 4 | Allow-Internet | Any Client | WAN | Any | Allow | Always | Internet access | +| 5 | Block-MGMT | Any Client | 192.168.20.0/24 | Any | Deny | Always | No management | +| 6 | Block-CCTV | Any Client | 192.168.40.0/24 | Any | Deny | Always | No CCTV access | +| 7 | Block-Voice | Any Client | 192.168.50.0/24 | Any | Deny | Always | No Voice access | +| 8 | Block-Client2Client | Any Client | Any Client | Any | Deny | Always | Client isolation | -*SSID: GUEST (Guest WiFi)* -| # | Name | Source | Destination | Service | Action | Schedule | Notes | +_SSID: GUEST (Guest WiFi)_ +| # | Name | Source | Destination | Service | Action | Schedule | Notes | | --- | ------------------- | ---------- | ---------------- | ---------- | ------ | -------- | ------------------ | -| 1 | Allow-DNS | Any Client | 8.8.8.8, 1.1.1.1 | DNS (53) | Allow | Always | DNS resolution | -| 2 | Allow-HTTP | Any Client | WAN | HTTP/HTTPS | Allow | Always | Web browsing | -| 3 | Block-RFC1918 | Any Client | 10.0.0.0/8 | Any | Deny | Always | No private IPs | -| 4 | Block-RFC1918-2 | Any Client | 172.16.0.0/12 | Any | Deny | Always | No private IPs | -| 5 | Block-RFC1918-3 | Any Client | 192.168.0.0/16 | Any | Deny | Always | No internal access | -| 6 | Block-Client2Client | Any Client | Any Client | Any | Deny | Always | Client isolation | +| 1 | Allow-DNS | Any Client | 8.8.8.8, 1.1.1.1 | DNS (53) | Allow | Always | DNS resolution | +| 2 | Allow-HTTP | Any Client | WAN | HTTP/HTTPS | Allow | Always | Web browsing | +| 3 | Block-RFC1918 | Any Client | 10.0.0.0/8 | Any | Deny | Always | No private IPs | +| 4 | Block-RFC1918-2 | Any Client | 172.16.0.0/12 | Any | Deny | Always | No private IPs | +| 5 | Block-RFC1918-3 | Any Client | 192.168.0.0/16 | Any | Deny | Always | No internal access | +| 6 | Block-Client2Client | Any Client | Any Client | Any | Deny | Always | Client isolation | -*Rate Limiting* -| SSID | Download Limit | Upload Limit | Notes | +_Rate Limiting_ +| SSID | Download Limit | Upload Limit | Notes | | ------- | -------------- | ------------ | ----------------------- | -| PSLCBP3 | Unlimited | Unlimited | Staff full speed | -| GUEST | 10 Mbps | 5 Mbps | Guest bandwidth control | +| PSLCBP3 | Unlimited | Unlimited | Staff full speed | +| GUEST | 10 Mbps | 5 Mbps | Guest bandwidth control | -*Captive Portal (GUEST SSID)* -| Setting | Value | Notes | +_Captive Portal (GUEST SSID)_ +| Setting | Value | Notes | | ---------------- | --------------- | ---------------------- | -| Portal Type | Simple Password | Single shared password | -| Session Timeout | 8 Hours | Re-auth after 8 hours | -| Idle Timeout | 30 Minutes | Disconnect if idle | -| Terms of Service | Enabled | User must accept ToS | +| Portal Type | Simple Password | Single shared password | +| Session Timeout | 8 Hours | Re-auth after 8 hours | +| Idle Timeout | 30 Minutes | Disconnect if idle | +| Terms of Service | Enabled | User must accept ToS | > **หมายเหตุ**: EAP ACL ทำงานที่ Layer 3 บน Omada Controller ช่วยลด load บน ER7206 - **Network Topology Diagram** + ```mermaid graph TB subgraph Internet @@ -373,18 +360,16 @@ graph TB CS -->|Port 24| ADMIN_SW ``` - **OC200 Omada Controller Configuration** -| Setting | Value | Notes | +| Setting | Value | Notes | | --------------- | -------------------------- | ------------------------------ | -| Controller IP | 192.168.20.10 | Static IP in MGMT VLAN | -| Controller Port | 8043 (HTTPS) | Management Web UI | -| Adoption URL | https://192.168.20.10:8043 | URL for AP adoption | -| Site Name | LCBP3 | Single site configuration | -| Managed Devices | 16x EAP610 | All APs managed centrally | -| Firmware Update | Manual | Test before production rollout | -| Backup Schedule | Weekly (Sunday 2AM) | Auto backup to QNAP | - +| Controller IP | 192.168.20.10 | Static IP in MGMT VLAN | +| Controller Port | 8043 (HTTPS) | Management Web UI | +| Adoption URL | https://192.168.20.10:8043 | URL for AP adoption | +| Site Name | LCBP3 | Single site configuration | +| Managed Devices | 16x EAP610 | All APs managed centrally | +| Firmware Update | Manual | Test before production rollout | +| Backup Schedule | Weekly (Sunday 2AM) | Auto backup to QNAP | ## **2.3 การจัดการ Configuration (ปรับปรุง):** @@ -405,14 +390,12 @@ graph TB ## **2.4 Core Services:** - Code Hosting: Gitea (Self-hosted on QNAP) - - Application name: git - Service name: gitea - Domain: git.np-dms.work - หน้าที่: เป็นศูนย์กลางในการเก็บและจัดการเวอร์ชันของโค้ด (Source Code) สำหรับทุกส่วน - Backend / Data Platform: NestJS - - Application name: lcbp3-backend - Service name: backend - Domain: backend.np-dms.work @@ -420,7 +403,6 @@ graph TB - หน้าที่: จัดการโครงสร้างข้อมูล (Data Models), สร้าง API, จัดการสิทธิ์ผู้ใช้ (Roles & Permissions), และสร้าง Workflow ทั้งหมดของระบบ - Database: MariaDB 11.8 - - Application name: lcbp3-db - Service name: mariadb - Domain: db.np-dms.work @@ -428,7 +410,6 @@ graph TB - Tooling: DBeaver (Community Edition), phpmyadmin สำหรับการออกแบบและจัดการฐานข้อมูล - Database Management: phpMyAdmin - - Application name: lcbp3-db - Service: phpmyadmin:5-apache - Service name: pma @@ -436,7 +417,6 @@ graph TB - หน้าที่: จัดการฐานข้อมูล mariadb ผ่าน Web UI - Frontend: Next.js - - Application name: lcbp3-frontend - Service name: frontend - Domain: lcbp3.np-dms.work @@ -446,7 +426,6 @@ graph TB - หน้าที่: สร้างหน้าตาเว็บแอปพลิเคชันสำหรับให้ผู้ใช้งานเข้ามาดู Dashboard, จัดการเอกสาร, และติดตามงาน โดยจะสื่อสารกับ Backend ผ่าน API - Workflow Automation: n8n - - Application name: lcbp3-n8n - Service: n8nio/n8n:latest - Service name: n8n @@ -454,7 +433,6 @@ graph TB - หน้าที่: จัดการ workflow ระหว่าง Backend และ Line - Reverse Proxy: Nginx Proxy Manager - - Application name: lcbp3-npm - Service: Nginx Proxy Manager (nginx-proxy-manage: latest) - Service name: npm @@ -467,20 +445,16 @@ graph TB ## **2.5 Business Logic & Consistency (ปรับปรุง):** - 2.5.1 Unified Workflow Engine (หลัก): - - ระบบการเดินเอกสารทั้งหมด (Correspondence, RFA, Circulation) ต้อง ใช้ Engine กลางเดียวกัน โดยกำหนด Logic ผ่าน Workflow DSL (JSON Configuration) แทนการเขียน Hard-coded ลงในตาราง - Workflow Versioning (เพิ่ม): ระบบต้องรองรับการกำหนด Version ของ Workflow Definition โดยเอกสารที่เริ่มกระบวนการไปแล้ว (In-progress instances) จะต้องใช้ Workflow Version เดิม จนกว่าจะสิ้นสุดกระบวนการ หรือได้รับคำสั่ง Migrate จาก Admin เพื่อป้องกันความขัดแย้งของ State - 2.5.2 Separation of Concerns: - - Module ต่างๆ (Correspondence, RFA, Circulation) จะเก็บเฉพาะข้อมูลของเอกสาร (Data) ส่วนสถานะและการเปลี่ยนสถานะ (State Transition) จะถูกจัดการโดย Workflow Engine - 2.5.3 Idempotency & Locking: - - ใช้กลไกเดิมในการป้องกันการทำรายการซ้ำ - 2.5.4 Optimistic Locking: - - ใช้ Version Column ใน Database ควบคู่กับ Redis Lock สำหรับการสร้างเลขที่เอกสาร เพื่อเป็น Safety Net ชั้นสุดท้าย - 2.5.5 จะไม่มีการใช้ SQL Triggers @@ -502,4 +476,3 @@ graph TB - 2.7.3 Fallback Strategies: Graceful degradation เมื่อบริการภายนอกล้มเหลว - 2.7.4 Error Handling: Error messages ต้องไม่เปิดเผยข้อมูล sensitive - 2.6.5 Monitoring: Centralized error monitoring และ alerting system - diff --git a/specs/99-archives/01-03.11-document-numbering.md b/specs/99-archives/01-03.11-document-numbering.md index 8e0a249..7919dea 100644 --- a/specs/99-archives/01-03.11-document-numbering.md +++ b/specs/99-archives/01-03.11-document-numbering.md @@ -1,29 +1,32 @@ # 3.11 Document Numbering Management (การจัดการเลขที่เอกสาร) --- + title: 'Functional Requirements: Document Numbering Management' version: 1.6.2 status: draft owner: Nattanin Peancharoen last_updated: 2025-12-17 related: - - specs/01-requirements/01-01-objectives.md - - specs/01-requirements/01-02-architecture.md - - specs/01-requirements/01-03-functional-requirements.md - - specs/01-requirements/01-03.11-document-numbering.md - - specs/03-implementation/03-04-document-numbering.md - - specs/04-operations/04-08-document-numbering-operations.md - - specs/07-database/07-01-data-dictionary-v1.7.0.md - - specs/05-decisions/ADR-002-document-numbering-strategy.md -Clean Version v1.6.2 – Scope of Changes: - - เลือกใช้ Single Numbering System (Option A) - - แก้ Primary Key design ให้ implement ได้จริง - - ปรับ Character Rule เป็น UTF‑8 printable - - Bind Reset Policy ชัดเจน (Yearly reset, RFA no reset) - - เพิ่ม Number State Machine - - เพิ่ม Idempotency Key - - Drawing ใช้ separate counter namespace - - เพิ่ม Formal Token Validation Grammar + +- specs/01-requirements/01-01-objectives.md +- specs/01-requirements/01-02-architecture.md +- specs/01-requirements/01-03-functional-requirements.md +- specs/01-requirements/01-03.11-document-numbering.md +- specs/03-implementation/03-04-document-numbering.md +- specs/04-operations/04-08-document-numbering-operations.md +- specs/07-database/07-01-data-dictionary-v1.7.0.md +- specs/05-decisions/ADR-002-document-numbering-strategy.md + Clean Version v1.6.2 – Scope of Changes: +- เลือกใช้ Single Numbering System (Option A) +- แก้ Primary Key design ให้ implement ได้จริง +- ปรับ Character Rule เป็น UTF‑8 printable +- Bind Reset Policy ชัดเจน (Yearly reset, RFA no reset) +- เพิ่ม Number State Machine +- เพิ่ม Idempotency Key +- Drawing ใช้ separate counter namespace +- เพิ่ม Formal Token Validation Grammar + --- > **📖 เอกสารที่เกี่ยวข้อง** @@ -72,27 +75,33 @@ Clean Version v1.6.2 – Scope of Changes: - Circulation Sheets (CIR) ### 3.11.1.5 Architectural Decision (Updated) + AD-DN-001: Single Source of Truth for Numbering ระบบ เลือกใช้ Option A: - - document_number_counters เป็น Core / Authoritative Counter System - - document_numbering_configs ใช้เฉพาะ: - - Template format - - Permission / policy - - ยกเลิกการใช้ document_numbering_sequences เป็น counter จริง -เหตุผล: ลดความซ้ำซ้อน, ป้องกัน counter mismatch, debug ง่าย, ops ชัดเจน + +- document_number_counters เป็น Core / Authoritative Counter System +- document_numbering_configs ใช้เฉพาะ: + - Template format + - Permission / policy +- ยกเลิกการใช้ document_numbering_sequences เป็น counter จริง + เหตุผล: ลดความซ้ำซ้อน, ป้องกัน counter mismatch, debug ง่าย, ops ชัดเจน + --- + ## 3.11.2 Counter Logic & Reset Policy + ### 3.11.2 Counter Logic (Logic การนับเลข) การนับเลขจะแยกตาม **Counter Key** ที่ประกอบด้วยหลายส่วน ขึ้นกับประเภทเอกสาร -| Document Type | Reset Policy | -| ------------- | ------------ | -| Correspondence (LETTER, MEMO, RFI, etc.) | Yearly reset | -| Transmittal | Yearly reset | -| RFA | No reset (continuous) | -| Drawing | Separate namespace (see 3.11.8) | +| Document Type | Reset Policy | +| ---------------------------------------- | ------------------------------- | +| Correspondence (LETTER, MEMO, RFI, etc.) | Yearly reset | +| Transmittal | Yearly reset | +| RFA | No reset (continuous) | +| Drawing | Separate namespace (see 3.11.8) | ### 3.11.2.2 Counter Key Fields (Revised) + ``` (project_id, originator_organization_id, @@ -104,50 +113,55 @@ AD-DN-001: Single Source of Truth for Numbering ระบบ เลือกใ reset_scope) ``` -* `reset_scope`: - * `YEAR_2025`, `YEAR_2026`, ... - * `NONE` (สำหรับ RFA) +- `reset_scope`: + - `YEAR_2025`, `YEAR_2026`, ... + - `NONE` (สำหรับ RFA) ### 3.11.2.3 Counter Key Components -| Component | Required? | Description | Database Source | Default if NULL | -| ---------------------------- | ---------------- | ------------------- | --------------------------------------------------------- | --------------- | -| `project_id` | ✅ Yes | ID โครงการ | Derived from user context or organization | - | -| `originator_organization_id` | ✅ Yes | ID องค์กรผู้ส่ง | `correspondences.originator_id` | - | +| Component | Required? | Description | Database Source | Default if NULL | +| ---------------------------- | ---------------- | ------------------------ | --------------------------------------------------------- | --------------- | +| `project_id` | ✅ Yes | ID โครงการ | Derived from user context or organization | - | +| `originator_organization_id` | ✅ Yes | ID องค์กรผู้ส่ง | `correspondences.originator_id` | - | | `recipient_organization_id` | Depends on type | ID องค์กรผู้รับหลัก (TO) | `correspondence_recipients` where `recipient_type = 'TO'` | 0 for RFA | -| `correspondence_type_id` | ✅ Yes | ID ประเภทเอกสาร | `correspondence_types.id` | 0 | -| `sub_type_id` | TRANSMITTAL only | ID ประเภทย่อย | `correspondence_sub_types.id` | 0 | -| `rfa_type_id` | RFA only | ID ประเภท RFA | `rfa_types.id` | 0 | -| `discipline_id` | RFA only | ID สาขางาน | `disciplines.id` | 0 | -| `reset_scope` | ✅ Yes | ขอบเขต reset | System derived | - | +| `correspondence_type_id` | ✅ Yes | ID ประเภทเอกสาร | `correspondence_types.id` | 0 | +| `sub_type_id` | TRANSMITTAL only | ID ประเภทย่อย | `correspondence_sub_types.id` | 0 | +| `rfa_type_id` | RFA only | ID ประเภท RFA | `rfa_types.id` | 0 | +| `discipline_id` | RFA only | ID สาขางาน | `disciplines.id` | 0 | +| `reset_scope` | ✅ Yes | ขอบเขต reset | System derived | - | ### 3.11.2.4 Counter Key by Document Type #### **Global (LETTER / MEMO / RFI / EMAIL / INSTRUCTION / NOTICE / OTHER)**: + ``` (project_id, originator_organization_id, recipient_organization_id, correspondence_type_id, 0, 0, 0, 'YEAR_2025') ``` **หมายเหตุ**: + - ไม่ใช้ `discipline_id`, `sub_type_id`, `rfa_type_id` - ถ้ามีการเพิ่ม correspondence type ใหม่ใน `correspondence_types` table จะใช้ Template นี้โดยอัตโนมัติ #### **TRANSMITTAL**: + ``` (project_id, originator_organization_id, recipient_organization_id, correspondence_type_id, sub_type_id, 0, 0, 'YEAR_2025') ``` -*หมายเหตุ*: ใช้ `sub_type_id` เพิ่มเติม +_หมายเหตุ_: ใช้ `sub_type_id` เพิ่มเติม #### **RFA**: + ``` (project_id, originator_organization_id, 0, correspondence_type_id, 0, rfa_type_id, discipline_id, 'NONE') ``` -*หมายเหตุ*: +_หมายเหตุ_: + - RFA ไม่ใช้ `recipient_organization_id` (ใช้ 0) เพราะเป็นเอกสารโครงการ (CONTRACTOR → CONSULTANT → OWNER) - ไม่มี yearly reset (`reset_scope = 'NONE'`) @@ -181,6 +195,7 @@ AD-DN-001: Single Source of Truth for Numbering ระบบ เลือกใ ## 3.11.3 Format Templates by Correspondence Type > **📝 หมายเหตุสำคัญ** +> > - Templates ด้านล่างเป็น **ตัวอย่าง** สำหรับประเภทเอกสารหลัก > - ระบบรองรับ **ทุกประเภทเอกสาร** ที่อยู่ใน `correspondence_types` table > - หากมีการเพิ่มประเภทใหม่ในอนาคต สามารถใช้งานได้โดยอัตโนมัติ @@ -189,6 +204,7 @@ AD-DN-001: Single Source of Truth for Numbering ระบบ เลือกใ ### 3.11.3.1 Global (correspondence_type_id = defined) **Template**: + ``` {ORIGINATOR}-{RECIPIENT}-{SEQ:4}-{YEAR:B.E.} ``` @@ -215,6 +231,7 @@ AD-DN-001: Single Source of Truth for Numbering ระบบ เลือกใ ### 3.11.3.2 Transmittal (TYPE = TRANSMITTAL) **Template**: + ``` {ORIGINATOR}-{RECIPIENT}-{SUB_TYPE}-{SEQ:4}-{YEAR:B.E.} ``` @@ -256,10 +273,10 @@ AD-DN-001: Single Source of Truth for Numbering ระบบ เลือกใ --- - ### 3.11.3.4 Drawing #### 3.11.3.4.1 Shop Drawing + **Example**: `LCBP3-C2-SDW-SW-BST-002-1` **Token Breakdown**: @@ -292,20 +309,20 @@ AD-DN-001: Single Source of Truth for Numbering ระบบ เลือกใ ## 3.11.4 Supported Token Types -| Token | Description | Example | Database Source | -| -------------- | ---------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------- | -| `{PROJECT}` | รหัสโครงการ | `LCBP3`, `LCBP3-C2` | `projects.project_code` | -| `{ORIGINATOR}` | รหัสองค์กรผู้ส่ง | `คคง.`, `ผรม.1` | `organizations.organization_code` via `correspondences.originator_id` | -| `{RECIPIENT}` | รหัสองค์กรผู้รับหลัก (TO) | `สคฉ.3`, `กทท.` | `organizations.organization_code` via `correspondence_recipients` where `recipient_type = 'TO'` | -| `{CORR_TYPE}` | รหัสประเภทเอกสาร | `RFA`, `TRANSMITTAL`, `LETTER` | `correspondence_types.type_code` | -| `{SUB_TYPE}` | หมายเลขประเภทย่อย | `11`, `12`, `21` | `correspondence_sub_types.sub_type_number` | -| `{RFA_TYPE}` | รหัสประเภท RFA | `SDW`, `RPT`, `MAT` | `rfa_types.type_code` | +| Token | Description | Example | Database Source | +| -------------- | ------------------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------- | +| `{PROJECT}` | รหัสโครงการ | `LCBP3`, `LCBP3-C2` | `projects.project_code` | +| `{ORIGINATOR}` | รหัสองค์กรผู้ส่ง | `คคง.`, `ผรม.1` | `organizations.organization_code` via `correspondences.originator_id` | +| `{RECIPIENT}` | รหัสองค์กรผู้รับหลัก (TO) | `สคฉ.3`, `กทท.` | `organizations.organization_code` via `correspondence_recipients` where `recipient_type = 'TO'` | +| `{CORR_TYPE}` | รหัสประเภทเอกสาร | `RFA`, `TRANSMITTAL`, `LETTER` | `correspondence_types.type_code` | +| `{SUB_TYPE}` | หมายเลขประเภทย่อย | `11`, `12`, `21` | `correspondence_sub_types.sub_type_number` | +| `{RFA_TYPE}` | รหัสประเภท RFA | `SDW`, `RPT`, `MAT` | `rfa_types.type_code` | | `{DISCIPLINE}` | รหัสสาขาวิชา | `STR`, `TER`, `GEO` | `disciplines.discipline_code` | | `{SEQ:n}` | Running number (n = จำนวนหลัก) | `0001`, `0029`, `0985` | Based on `document_number_counters.last_number + 1` | -| `{YEAR:B.E.}` | ปี พ.ศ. | `2568` | `reset_scope` + 543 | -| `{YEAR:A.D.}` | ปี ค.ศ. | `2025` | `reset_scope` | -| `{REV}` | Revision Code | `A`, `B`, `AA` | `correspondence_revisions.revision_label` | -| `{PREFIX}` | คำนำหน้าตามประเภทเอกสาร | `COR`, `RFA` | Configurable prefix | +| `{YEAR:B.E.}` | ปี พ.ศ. | `2568` | `reset_scope` + 543 | +| `{YEAR:A.D.}` | ปี ค.ศ. | `2025` | `reset_scope` | +| `{REV}` | Revision Code | `A`, `B`, `AA` | `correspondence_revisions.revision_label` | +| `{PREFIX}` | คำนำหน้าตามประเภทเอกสาร | `COR`, `RFA` | Configurable prefix | | `{YYYY}` | ปี 4 หลัก | `2025` | Current year | | `{YY}` | ปี 2 หลัก | `25` | Current year (short) | | `{MM}` | เดือน 2 หลัก | `01-12` | Current month | @@ -345,17 +362,18 @@ AD-DN-001: Single Source of Truth for Numbering ระบบ เลือกใ ### BR-DN-002 (Revised) -* Document number **must be printable UTF‑8** -* Disallowed: - * Control characters - * Newlines / tabs -* Allowed: - * Thai - * English - * Numbers - * `-`, `_`, `.` +- Document number **must be printable UTF‑8** +- Disallowed: + - Control characters + - Newlines / tabs +- Allowed: + - Thai + - English + - Numbers + - `-`, `_`, `.` ### BR-DN-003: Number Format Rules + - Min length: 10 characters - Max length: 50 characters - Must include {SEQ:n} token exactly once @@ -373,14 +391,14 @@ RESERVED → CONFIRMED → VOID ### Rules -* **RESERVED**: - * TTL 5 minutes - * Auto-expire → CANCELLED -* **CONFIRMED**: - * Linked to document_id -* **VOID**: - * Only CONFIRMED numbers - * Replacement creates new number +- **RESERVED**: + - TTL 5 minutes + - Auto-expire → CANCELLED +- **CONFIRMED**: + - Linked to document_id +- **VOID**: + - Only CONFIRMED numbers + - Replacement creates new number --- @@ -388,7 +406,7 @@ RESERVED → CONFIRMED → VOID ### API Requirement -* All number generation APIs **must** support: +- All number generation APIs **must** support: ```http Idempotency-Key: UUID @@ -396,21 +414,21 @@ Idempotency-Key: UUID ### Behavior -* Same key + same payload → return same number -* Prevents double submit / retry duplication +- Same key + same payload → return same number +- Prevents double submit / retry duplication --- ## 3.11.8 Drawing Numbering (Clarified) -* Drawing numbering **does not use** this counter table -* Uses **separate counter namespace**: +- Drawing numbering **does not use** this counter table +- Uses **separate counter namespace**: ```text DRAWING:::: ``` -* Prevents collision with correspondence/RFA +- Prevents collision with correspondence/RFA --- @@ -430,9 +448,9 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ### Validation Rules -* Must include `{SEQ:n}` exactly once -* Unknown tokens → validation error -* Max template length: 50 chars +- Must include `{SEQ:n}` exactly once +- Unknown tokens → validation error +- Max template length: 50 chars --- @@ -441,12 +459,14 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ### 3.11.10.1 Auto Number Generation #### FR-DN-001: Generate Sequential Number + **Priority**: CRITICAL | **Status**: Required **Description**: ระบบต้องสามารถสร้างเลขที่เอกสารอัตโนมัติตามลำดับ (sequential) โดยไม่ซ้ำกัน **Acceptance Criteria**: + - เลขที่เอกสารต้องเป็น unique ในscope ที่กำหนด - ต้องเพิ่มขึ้นทีละ 1 (increment by 1) - ต้องรองรับ concurrent requests โดยไม่มีเลขที่ซ้ำ @@ -455,12 +475,14 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" --- #### FR-DN-002: Configurable Number Format + **Priority**: HIGH | **Status**: Required **Description**: ระบบต้องรองรับการกำหนดรูปแบบเลขที่เอกสารที่หลากหลาย **Acceptance Criteria**: + - รองรับ format tokens ที่ระบุ - Admin สามารถกำหนด format ผ่าน UI ได้ - Validate format ก่อน save @@ -469,12 +491,14 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" --- #### FR-DN-003: Scope-based Sequences + **Priority**: HIGH | **Status**: Required **Description**: ระบบต้องรองรับการสร้าง sequence ที่แยกตาม scope ที่ต่างกัน **Acceptance Criteria**: + - เลขที่ไม่ซ้ำภายใน scope เดียวกัน - Scope ที่ต่างกันสามารถมีเลขที่เดียวกันได้ - Support multiple active scopes @@ -484,17 +508,20 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ### 3.11.10.2 Manual Override #### FR-DN-004: Manual Number Assignment + **Priority**: HIGH | **Status**: Required **Description**: ระบบต้องรองรับการกำหนดเลขที่เอกสารด้วยตนเอง (manual override) **Use Cases**: + 1. Import เอกสารเก่าจากระบบเดิม 2. External documents จาก client/consultant 3. Correction หลังพบความผิดพลาด **Acceptance Criteria**: + - ตรวจสอบ duplicate ก่อน save - Validate format ตามรูปแบบที่กำหนด - Auto-update sequence counter ถ้าเลขที่สูงกว่า current @@ -504,12 +531,14 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" --- #### FR-DN-005: Bulk Import Support + **Priority**: MEDIUM | **Status**: Required **Description**: ระบบต้องรองรับการ import เอกสารหลายรายการพร้อมกัน **Acceptance Criteria**: + - รองรับไฟล์ CSV/Excel - Validate ทุกรายการก่อน import - แสดง preview ก่อน confirm @@ -522,17 +551,20 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ### 3.11.10.3 Cancelled & Void Handling #### FR-DN-006: Skip Cancelled Numbers + **Priority**: HIGH | **Status**: Required **Description**: เลขที่เอกสารที่ถูกยกเลิกต้องไม่ถูก reuse **Rationale**: + - รักษา audit trail ที่ชัดเจน - ป้องกันความสับสน - Legal compliance **Acceptance Criteria**: + - Cancelled number ยังคงอยู่ในฐานข้อมูลพร้อม status - ระบบข้าม (skip) cancelled number เมื่อสร้างเลขที่ใหม่ - บันทึกเหตุผลการยกเลิก @@ -541,12 +573,14 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" --- #### FR-DN-007: Void and Replace + **Priority**: HIGH | **Status**: Required **Description**: ระบบต้องรองรับการ void เอกสารและสร้างเอกสารใหม่แทน **Workflow**: + 1. User เลือกเอกสารที่ต้องการ void 2. ระบุเหตุผล (required) 3. ระบบเปลี่ยน status เอกสารเดิมเป็น VOID @@ -554,6 +588,7 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" 5. Link เอกสารใหม่กับเดิม (voided_from_id) **Acceptance Criteria**: + - เอกสารเดิม status = VOID (ไม่ลบ) - เอกสารใหม่ได้เลขที่ต่อเนื่องจาก sequence - มี reference link ระหว่างเอกสาร @@ -565,17 +600,20 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ### 3.11.10.4 Concurrency & Performance #### FR-DN-008: Prevent Race Conditions + **Priority**: CRITICAL | **Status**: Required **Description**: ระบบต้องป้องกันการสร้างเลขที่ซ้ำเมื่อมีการ request พร้อมกัน **Solution**: + - Distributed locking (Redlock) - Database pessimistic locking - Two-phase commit pattern **Acceptance Criteria**: + - Zero duplicate numbers ภายใต้ concurrent load (1000 req/s) - Lock acquisition time < 50ms (avg) - Automatic retry on lock failure (max 3 times) @@ -584,36 +622,43 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" --- #### FR-DN-009: Two-Phase Commit + **Priority**: HIGH | **Status**: Required **Description**: ใช้ Two-phase commit pattern เพื่อความสมบูรณ์ของข้อมูล **Phase 1: Reserve** + - ล็อกเลขที่และ reserve ไว้ชั่วคราว - Set TTL 5 นาที - Return reservation token **Phase2: Confirm or Cancel** + - Confirm: บันทึกลงฐานข้อมูลถาวร - Cancel: คืน lock และ reservation **Acceptance Criteria**: + - Reservation ต้อง expire หลัง 5 นาที - Auto-cleanup expired reservations - Support explicit cancel - Idempotent confirmation --- + ### 3.11.10.5 Monitoring & Audit #### FR-DN-010: Complete Audit Trail + **Priority**: HIGH | **Status**: Required **Description**: บันทึกทุก operation ที่เกิดขึ้นกับเลขที่เอกสาร **Events to Log**: + - Number reserved - Number confirmed - Number cancelled @@ -623,6 +668,7 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" - Format changed **Acceptance Criteria**: + - Log ทุก operation - Searchable by user, date, type - Export to CSV @@ -631,12 +677,14 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" --- #### FR-DN-011: Metrics & Alerting + **Priority**: MEDIUM | **Status**: Required **Description**: แสดงสถิติและส่ง alert เมื่อเกิดปัญหา **Metrics**: + - Sequence utilization (% of max) - Average lock wait time - Failed lock attempts @@ -644,6 +692,7 @@ DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" - Manual overrides per day **Alerts**: + - Sequence >90% used (WARNING) - Sequence >95% used (CRITICAL) - Lock wait time >1s (WARNING) @@ -690,10 +739,10 @@ CREATE TABLE document_number_counters ( ### Rules -* RFA → `recipient_organization_id = 0` -* Reset: - * Yearly: `reset_scope = 'YEAR_2025'` - * No reset: `reset_scope = 'NONE'` +- RFA → `recipient_organization_id = 0` +- Reset: + - Yearly: `reset_scope = 'YEAR_2025'` + - No reset: `reset_scope = 'NONE'` ### 3.11.11.2 Index Requirements @@ -781,6 +830,7 @@ CREATE TABLE document_number_audit ( ``` ### 3.11.11.5 Reservation Table + ```sql CREATE TABLE document_number_reservations ( id BIGINT AUTO_INCREMENT PRIMARY KEY, @@ -829,6 +879,7 @@ CREATE TABLE document_number_reservations ( COMMENT='Document Number Reservations - Two-Phase Commit'; ``` + ### 3.11.11.5 Error Log Table ```sql @@ -875,12 +926,14 @@ CREATE TABLE document_number_config_history ( ### 3.11.11.7 Important Notes > **💡 Counter Key Design** +> > - `recipient_organization_id` ใช้ `0` สำหรับ RFA (ไม่มี specific recipient) > - `version` column สำหรับ Optimistic Locking (ป้องกัน race condition) > - `last_number` เริ่มจาก 0 และเพิ่มขึ้นทีละ 1 > - Counter reset ทุกปี (เมื่อ `reset_scope` เปลี่ยน) > **⚠️ Migration Notes** +> > - ไม่มีข้อมูลเก่า ไม่ต้องทำ backward compatibility > - สามารถสร้าง table ใหม่ได้เลยตาม schema ข้างต้น > - ต้องมี seed data สำหรับ `correspondence_types`, `rfa_types`, `disciplines` ก่อน @@ -953,6 +1006,7 @@ INSERT INTO document_number_counters ( ### 3.11.12.4 Rate Limiting **Requirements:** + - Limit ต่อ user: **10 requests/minute** (prevent abuse) - Limit ต่อ IP: **50 requests/minute** @@ -1049,14 +1103,14 @@ INSERT INTO document_number_counters ( **SLA Targets:** -| Metric | Target | Notes | -| ----------------- | -------- | ------------------------ | +| Metric | Target | Notes | +| ----------------- | ---------- | ---------------------------- | | 95th percentile | ≤ 2 วินาที | ตั้งแต่ request ถึง response | -| 99th percentile | ≤ 5 วินาที | รวม retry attempts | -| Normal operation | ≤ 500ms | ไม่มี retry | -| Number generation | < 100ms | (p95) | -| Lock acquisition | < 50ms | (avg) | -| Bulk import | < 5s | per 100 records | +| 99th percentile | ≤ 5 วินาที | รวม retry attempts | +| Normal operation | ≤ 500ms | ไม่มี retry | +| Number generation | < 100ms | (p95) | +| Lock acquisition | < 50ms | (avg) | +| Bulk import | < 5s | per 100 records | ### 3.11.16.2 Throughput @@ -1064,8 +1118,8 @@ INSERT INTO document_number_counters ( | Load Level | Target | Notes | | ----------- | ----------- | ------------------ | -| Normal load | ≥ 50 req/s | ใช้งานปกติ | -| Peak load | ≥ 100 req/s | ช่วงเร่งงาน | +| Normal load | ≥ 50 req/s | ใช้งานปกติ | +| Peak load | ≥ 100 req/s | ช่วงเร่งงาน | | Burst | ≥ 200 req/s | short duration | | Support | > 500 req/s | Scale horizontally | @@ -1106,8 +1160,8 @@ INSERT INTO document_number_counters ( ระบบ**ต้อง**alert สำหรับ conditions ต่อไปนี้: -| Severity | Condition | Action | -| ---------- | ---------------------------- | ----------------- | +| Severity | Condition | Action | +| ----------- | ---------------------------- | ----------------- | | 🔴 Critical | Redis unavailable > 1 minute | PagerDuty + Slack | | 🔴 Critical | Lock failures > 10% in 5 min | PagerDuty + Slack | | 🔴 Critical | Sequence >95% used | PagerDuty + Slack | @@ -1174,6 +1228,7 @@ POST /api/document-numbering/reserve ``` **Request:** + ```json { "document_type": "COR", @@ -1184,6 +1239,7 @@ POST /api/document-numbering/reserve ``` **Response 201:** + ```json { "token": "uuid-v4", @@ -1199,6 +1255,7 @@ POST /api/document-numbering/confirm ``` **Request:** + ```json { "token": "uuid-v4" @@ -1206,6 +1263,7 @@ POST /api/document-numbering/confirm ``` **Response 200:** + ```json { "document_number": "COR-2025-00042", @@ -1221,6 +1279,7 @@ Authorization: Bearer ``` **Request:** + ```json { "document_type": "COR", @@ -1231,6 +1290,7 @@ Authorization: Bearer ``` **Response 201:** + ```json { "document_number": "COR-2024-99999", @@ -1264,12 +1324,14 @@ Reset counter (Super Admin only, requires approval) ## 3.11.19 Testing Requirements ### 3.11.19.1 Unit Tests + - Format parsing and validation - Sequence increment logic - Manual override validation - Scope resolution ### 3.11.19.2 Integration Tests + - Redis locking mechanism - Database transactions - Two-phase commit flow @@ -1291,6 +1353,7 @@ expected_duplicates: 0 - Database connection pool exhaustion ### 3.11.19.4 E2E Tests + - Complete document creation flow - Void and replace workflow - Bulk import with validation @@ -1300,14 +1363,15 @@ expected_duplicates: 0 ## 3.11.20 Versioning Note -* Existing documents **not affected** -* New rules apply to documents generated after upgrade to v1.6.2 +- Existing documents **not affected** +- New rules apply to documents generated after upgrade to v1.6.2 --- ## 3.11.21 Migration Plan ### 3.11.21.1 Legacy Data Import + 1. Export existing document numbers from old system 2. Validate format and detect duplicates 3. Bulk import using manual override API @@ -1315,6 +1379,7 @@ expected_duplicates: 0 5. Verify data integrity ### 3.11.21.2 Rollout Strategy + - Week 1-2: Deploy to staging, test with dummy data - Week 3: Deploy to production, enable for test project - Week 4: Enable for all projects @@ -1325,18 +1390,21 @@ expected_duplicates: 0 ## 3.11.22 Success Criteria ### 3.11.22.1 Functional Success + - ✅ All FRs implemented and tested - ✅ Zero duplicate numbers in production - ✅ Migration of 50,000+ legacy documents - ✅ UAT approved by stakeholders ### 3.11.22.2 Performance Success + - ✅ Response time <100ms (p95) - ✅ Throughput >500 req/s - ✅ Lock acquisition <50ms (avg) - ✅ Zero downtime during deployment ### 3.11.22.3 Business Success + - ✅ Document creation speed +30% - ✅ Manual numbering errors -80% - ✅ User satisfaction >4.5/5 @@ -1359,6 +1427,7 @@ expected_duplicates: 0 ## 3.11.24 Appendix ### 3.11.24.1 Glossary + - **Sequence**: ลำดับตัวเลขที่เพิ่มขึ้นอัตโนมัติ - **Scope**: ขอบเขตที่ sequence แยกตาม (project, contract, etc.) - **Token**: Format placeholder (e.g., {YYYY}, {SEQ}) @@ -1368,10 +1437,10 @@ expected_duplicates: 0 **Approval Sign-off**: -| Role | Name | Date | Signature | -| ------------- | ----------- | ------- | --------- | -| Product Owner | ___________ | _______ | _________ | -| Tech Lead | ___________ | _______ | _________ | -| QA Lead | ___________ | _______ | _________ | +| Role | Name | Date | Signature | +| ------------- | -------------- | ---------- | ---------- | +| Product Owner | ****\_\_\_**** | **\_\_\_** | ****\_**** | +| Tech Lead | ****\_\_\_**** | **\_\_\_** | ****\_**** | +| QA Lead | ****\_\_\_**** | **\_\_\_** | ****\_**** | **End of Document v1.6.2** diff --git a/specs/99-archives/01-04-access-control.md b/specs/99-archives/01-04-access-control.md index 8f7a71e..f0150f0 100644 --- a/specs/99-archives/01-04-access-control.md +++ b/specs/99-archives/01-04-access-control.md @@ -68,11 +68,11 @@ related: ### **4.7. Master Data Management** -| Master Data | Manager | Scope | -| :-------------------------------------- | :------------------------------ | :------------------------------ | -| Document Type (Correspondence, RFA) | **Superadmin** | Global | -| Document Status (Draft, Approved, etc.) | **Superadmin** | Global | +| Master Data | Manager | Scope | +| :-------------------------------------- | :------------------------------ | :--------------------------------- | +| Document Type (Correspondence, RFA) | **Superadmin** | Global | +| Document Status (Draft, Approved, etc.) | **Superadmin** | Global | | Shop Drawing Category | **Project Manager** | Project (สร้างใหม่ได้ภายในโครงการ) | -| Tags | **Org Admin / Project Manager** | Organization / Project | -| Custom Roles | **Superadmin / Org Admin** | Global / Organization | -| Document Numbering Formats | **Superadmin / Admin** | Global / Organization | +| Tags | **Org Admin / Project Manager** | Organization / Project | +| Custom Roles | **Superadmin / Org Admin** | Global / Organization | +| Document Numbering Formats | **Superadmin / Admin** | Global / Organization | diff --git a/specs/99-archives/01_Infrastructure Setup.md b/specs/99-archives/01_Infrastructure Setup.md index 46ba162..a08467b 100644 --- a/specs/99-archives/01_Infrastructure Setup.md +++ b/specs/99-archives/01_Infrastructure Setup.md @@ -17,6 +17,7 @@ ## 1. Redis Configuration (Standalone + Persistence) ### 1.1 Docker Compose Setup + ```yaml # docker-compose-redis.yml version: '3.8' @@ -45,10 +46,10 @@ networks: external: true ``` - ## 2. Database Configuration ### 2.1 MariaDB Optimization for Numbering + ```sql -- /etc/mysql/mariadb.conf.d/50-numbering.cnf @@ -83,6 +84,7 @@ long_query_time = 1 ``` ### 2.2 Monitoring Locks + ```sql -- Check for lock contention SELECT @@ -110,6 +112,7 @@ KILL ; ### 3.1 Backend Service Deployment #### Docker Compose + ```yaml # docker-compose-backend.yml version: '3.8' @@ -126,7 +129,7 @@ services: - NUMBERING_LOCK_TIMEOUT=5000 - NUMBERING_RESERVATION_TTL=300 ports: - - "3001:3000" + - '3001:3000' depends_on: - mariadb - cache @@ -134,7 +137,7 @@ services: - lcbp3 restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + test: ['CMD', 'curl', '-f', 'http://localhost:3000/health'] interval: 30s timeout: 10s retries: 3 @@ -148,7 +151,7 @@ services: - REDIS_HOST=cache - REDIS_PORT=6379 ports: - - "3002:3000" + - '3002:3000' depends_on: - mariadb - cache @@ -162,6 +165,7 @@ networks: ``` #### Health Check Endpoint + ```typescript // health/numbering.health.ts import { Injectable } from '@nestjs/common'; @@ -173,17 +177,13 @@ import { DataSource } from 'typeorm'; export class NumberingHealthIndicator extends HealthIndicator { constructor( private redis: Redis, - private dataSource: DataSource, + private dataSource: DataSource ) { super(); } async isHealthy(key: string): Promise { - const checks = await Promise.all([ - this.checkRedis(), - this.checkDatabase(), - this.checkSequenceIntegrity(), - ]); + const checks = await Promise.all([this.checkRedis(), this.checkDatabase(), this.checkSequenceIntegrity()]); const isHealthy = checks.every((check) => check.status === 'up'); @@ -252,7 +252,7 @@ alerting: - alertmanager:9093 rule_files: - - "/etc/prometheus/alerts/numbering.yml" + - '/etc/prometheus/alerts/numbering.yml' scrape_configs: - job_name: 'backend' @@ -330,6 +330,7 @@ receivers: ### 4.3 Grafana Dashboards #### Import Dashboard JSON + ```bash # Download dashboard template curl -o numbering-dashboard.json \ @@ -342,6 +343,7 @@ curl -X POST http://admin:admin@localhost:3000/api/dashboards/db \ ``` #### Key Panels to Monitor + 1. **Numbers Generated per Minute** - Rate of number creation 2. **Sequence Utilization** - Current usage vs max (alert >90%) 3. **Lock Wait Time (p95)** - Performance indicator @@ -356,6 +358,7 @@ curl -X POST http://admin:admin@localhost:3000/api/dashboards/db \ ### 5.1 Database Backup Strategy #### Automated Backup Script + ```bash #!/bin/bash # scripts/backup-numbering-db.sh @@ -390,6 +393,7 @@ echo "✅ Backup complete: numbering_$DATE.sql.gz" ``` #### Cron Schedule + ```cron # Run backup daily at 2 AM 0 2 * * * /opt/lcbp3/scripts/backup-numbering-db.sh >> /var/log/numbering-backup.log 2>&1 @@ -401,6 +405,7 @@ echo "✅ Backup complete: numbering_$DATE.sql.gz" ### 5.2 Redis Backup #### Enable RDB Persistence + ```conf # redis.conf save 900 1 # Save if 1 key changed after 900 seconds @@ -417,6 +422,7 @@ appendfsync everysec ``` #### Backup Script + ```bash #!/bin/bash # scripts/backup-redis.sh @@ -456,6 +462,7 @@ echo "✅ Redis backup complete: redis_${DATE}.tar.gz" ### 5.3 Recovery Procedures #### Scenario 1: Restore from Database Backup + ```bash #!/bin/bash # scripts/restore-numbering-db.sh @@ -491,6 +498,7 @@ echo "🔄 Please verify sequence integrity" ``` #### Scenario 2: Redis Failure + ```bash # Check Redis status docker exec cache redis-cli ping @@ -512,6 +520,7 @@ docker exec cache redis-cli ping ### 6.1 Sequence Adjustment #### Increase Max Value + ```sql -- Check current utilization SELECT @@ -541,6 +550,7 @@ INSERT INTO document_numbering_audit_logs ( ``` #### Reset Yearly Sequence + ```sql -- For document types with yearly reset -- Run on January 1st @@ -606,6 +616,7 @@ LINES TERMINATED BY '\n'; ### 6.3 Redis Maintenance #### Flush Expired Reservations + ```bash #!/bin/bash # scripts/cleanup-expired-reservations.sh @@ -637,6 +648,7 @@ echo "✅ Cleaned up $COUNT expired reservations" ### 7.1 Total System Failure #### Recovery Steps + ```bash #!/bin/bash # scripts/disaster-recovery.sh @@ -699,7 +711,9 @@ echo "⚠️ Please verify system functionality manually" **Alert**: `SequenceWarning` or `SequenceCritical` **Steps**: + 1. Check current utilization + ```sql SELECT document_type, current_value, max_value, ROUND((current_value * 100.0 / max_value), 2) as pct @@ -714,6 +728,7 @@ echo "⚠️ Please verify system functionality manually" - Days until exhaustion? 3. Take action + ```sql -- Option A: Increase max_value UPDATE document_numbering_configs @@ -734,13 +749,16 @@ echo "⚠️ Please verify system functionality manually" **Alert**: `HighLockWaitTime` **Steps**: + 1. Check Redis cluster health + ```bash docker exec lcbp3-redis-1 redis-cli cluster info docker exec lcbp3-redis-1 redis-cli cluster nodes ``` 2. Check database locks + ```sql SELECT * FROM information_schema.innodb_lock_waits; SELECT * FROM information_schema.innodb_trx @@ -766,18 +784,22 @@ echo "⚠️ Please verify system functionality manually" **Alert**: `RedisUnavailable` **Steps**: + 1. Verify Redis is down + ```bash docker exec cache redis-cli ping || echo "Redis DOWN" ``` 2. Check system falls back to DB-only mode + ```bash curl http://localhost:3001/health/numbering # Should show: fallback_mode: true ``` 3. Restart Redis container + ```bash docker restart cache sleep 10 @@ -785,11 +807,13 @@ echo "⚠️ Please verify system functionality manually" ``` 4. If restart fails, restore from backup + ```bash ./scripts/restore-redis.sh /backups/redis/latest.tar.gz ``` 5. Verify numbering system back to normal + ```bash curl http://localhost:3001/health/numbering # Should show: fallback_mode: false @@ -807,6 +831,7 @@ echo "⚠️ Please verify system functionality manually" ### 9.1 Slow Number Generation **Diagnosis**: + ```sql -- Check slow queries SELECT * FROM mysql.slow_log @@ -821,6 +846,7 @@ FOR UPDATE; ``` **Optimizations**: + ```sql -- Add missing indexes CREATE INDEX idx_sequence_lookup @@ -888,8 +914,8 @@ networks: - subnet: 172.20.0.0/16 driver_opts: com.docker.network.bridge.name: lcbp3-br - com.docker.network.bridge.enable_icc: "true" - com.docker.network.bridge.enable_ip_masquerade: "true" + com.docker.network.bridge.enable_icc: 'true' + com.docker.network.bridge.enable_ip_masquerade: 'true' ``` --- @@ -902,3 +928,4 @@ networks: -- Export audit logs for compliance SELECT * FROM document_numbering +``` diff --git a/specs/99-archives/02-01-system-architecture.md b/specs/99-archives/02-01-system-architecture.md index 8ff75df..bde4068 100644 --- a/specs/99-archives/02-01-system-architecture.md +++ b/specs/99-archives/02-01-system-architecture.md @@ -22,6 +22,7 @@ ## 1. 🎯 Architecture Principles ### 1.1 Component Overview + ``` ┌──────────────────────────────────────────────────────┐ │ Load Balancer │ @@ -49,6 +50,7 @@ │Replicas │ └─────────┘ ``` + ### 1.2 Component Responsibilities | Component | Purpose | Critical? | @@ -121,14 +123,12 @@ graph TB **Configuration Strategy:** 1. **Production/Staging:** - - ใช้ `docker-compose.yml` สำหรับกำหนด Environment Variables - ห้ามระบุ Sensitive Secrets (Password, Keys) ใน `docker-compose.yml` หลัก - ใช้ `docker-compose.override.yml` (gitignored) สำหรับ Secrets - พิจารณาใช้ Docker Secrets หรือ Hashicorp Vault 2. **Development:** - - ใช้ `docker-compose.override.yml` สำหรับ Local Secrets - ไฟล์หลักใส่ค่า Dummy/Placeholder @@ -658,12 +658,10 @@ graph TB **File Upload Validation:** 1. **File Type Validation:** - - White-list: PDF, DWG, DOCX, XLSX, ZIP - Magic Number Verification (ไม่ใช่แค่ extension) 2. **File Size Validation:** - - Maximum: 50MB per file 3. **Virus Scanning:** @@ -889,18 +887,15 @@ GET /health/live # Liveness probe **Recovery Procedures:** 1. **Database Restoration:** - - Restore latest full backup - Apply transaction logs to point-in-time - Verify data integrity 2. **File Storage Restoration:** - - Restore from QNAP snapshot - Verify file permissions 3. **Application Redeployment:** - - Deploy from known-good Docker images - Verify health checks diff --git a/specs/99-archives/02-02-api-design.md b/specs/99-archives/02-02-api-design.md index 4297701..cfef98b 100644 --- a/specs/99-archives/02-02-api-design.md +++ b/specs/99-archives/02-02-api-design.md @@ -64,7 +64,6 @@ POST / api / v1 / auth / change - password; ### 2.2 Authorization (RBAC) - **4-Level Permission Hierarchy:** - 1. **Global Level:** System-wide permissions (Superadmin) 2. **Organization Level:** Organization-specific permissions 3. **Project Level:** Project-specific permissions @@ -97,32 +96,32 @@ https://backend.np-dms.work/api/v1/{resource} ### 3.2 HTTP Methods & Usage -| Method | Usage | Idempotent | Example | -| :------- | :--------------------------- | :--------- | :----------------------------------- | -| `GET` | ดึงข้อมูล (Read) | ✅ Yes | `GET /api/v1/correspondences` | -| `POST` | สร้างข้อมูลใหม่ (Create) | ❌ No\* | `POST /api/v1/correspondences` | -| `PUT` | อัปเดตทั้งหมด (Full Update) | ✅ Yes | `PUT /api/v1/correspondences/:id` | -| `PATCH` | อัปเดตบางส่วน (Partial Update) | ✅ Yes | `PATCH /api/v1/correspondences/:id` | -| `DELETE` | ลบข้อมูล (Soft Delete) | ✅ Yes | `DELETE /api/v1/correspondences/:id` | +| Method | Usage | Idempotent | Example | +| :------- | :----------------------------- | :--------- | :----------------------------------- | +| `GET` | ดึงข้อมูล (Read) | ✅ Yes | `GET /api/v1/correspondences` | +| `POST` | สร้างข้อมูลใหม่ (Create) | ❌ No\* | `POST /api/v1/correspondences` | +| `PUT` | อัปเดตทั้งหมด (Full Update) | ✅ Yes | `PUT /api/v1/correspondences/:id` | +| `PATCH` | อัปเดตบางส่วน (Partial Update) | ✅ Yes | `PATCH /api/v1/correspondences/:id` | +| `DELETE` | ลบข้อมูล (Soft Delete) | ✅ Yes | `DELETE /api/v1/correspondences/:id` | **Note:** `POST` เป็น Idempotent ได้เมื่อใช้ `Idempotency-Key` Header ### 3.3 HTTP Status Codes -| Status Code | Usage | -| :-------------------------- | :----------------------------- | +| Status Code | Usage | +| :-------------------------- | :------------------------------- | | `200 OK` | Request สำเร็จ (GET, PUT, PATCH) | -| `201 Created` | สร้างข้อมูลสำเร็จ (POST) | +| `201 Created` | สร้างข้อมูลสำเร็จ (POST) | | `204 No Content` | ลบสำเร็จ (DELETE) | -| `400 Bad Request` | ข้อมูล Request ไม่ถูกต้อง | -| `401 Unauthorized` | ไม่มี Token หรือ Token หมดอายุ | -| `403 Forbidden` | ไม่มีสิทธิ์เข้าถึง | -| `404 Not Found` | ไม่พบข้อมูล | -| `409 Conflict` | ข้อมูลซ้ำ หรือ State Conflict | -| `422 Unprocessable Entity` | Validation Error | -| `429 Too Many Requests` | Rate Limit Exceeded | -| `500 Internal Server Error` | Server Error | -| `503 Service Unavailable` | Maintenance Mode | +| `400 Bad Request` | ข้อมูล Request ไม่ถูกต้อง | +| `401 Unauthorized` | ไม่มี Token หรือ Token หมดอายุ | +| `403 Forbidden` | ไม่มีสิทธิ์เข้าถึง | +| `404 Not Found` | ไม่พบข้อมูล | +| `409 Conflict` | ข้อมูลซ้ำ หรือ State Conflict | +| `422 Unprocessable Entity` | Validation Error | +| `429 Too Many Requests` | Rate Limit Exceeded | +| `500 Internal Server Error` | Server Error | +| `503 Service Unavailable` | Maintenance Mode | ### 3.4 Request & Response Format @@ -304,28 +303,28 @@ POST /api/v1/files/upload | Method | Endpoint | Permission | Description | | :----- | :--------------------------------- | :---------------------- | :-------------------- | | GET | `/correspondences` | `correspondence.view` | รายการ Correspondence | -| GET | `/correspondences/:id` | `correspondence.view` | รายละเอียด | -| POST | `/correspondences` | `correspondence.create` | สร้างใหม่ | -| PUT | `/correspondences/:id` | `correspondence.update` | อัปเดตทั้งหมด | -| PATCH | `/correspondences/:id` | `correspondence.update` | อัปเดตบางส่วน | +| GET | `/correspondences/:id` | `correspondence.view` | รายละเอียด | +| POST | `/correspondences` | `correspondence.create` | สร้างใหม่ | +| PUT | `/correspondences/:id` | `correspondence.update` | อัปเดตทั้งหมด | +| PATCH | `/correspondences/:id` | `correspondence.update` | อัปเดตบางส่วน | | DELETE | `/correspondences/:id` | `correspondence.delete` | ลบ (Soft Delete) | -| POST | `/correspondences/:id/revisions` | `correspondence.update` | สร้าง Revision ใหม่ | -| GET | `/correspondences/:id/revisions` | `correspondence.view` | ดู Revisions ทั้งหมด | -| POST | `/correspondences/:id/attachments` | `correspondence.update` | เพิ่มไฟล์แนบ | +| POST | `/correspondences/:id/revisions` | `correspondence.update` | สร้าง Revision ใหม่ | +| GET | `/correspondences/:id/revisions` | `correspondence.view` | ดู Revisions ทั้งหมด | +| POST | `/correspondences/:id/attachments` | `correspondence.update` | เพิ่มไฟล์แนบ | ### 7.2 RFA Module **Base Path:** `/api/v1/rfas` -| Method | Endpoint | Permission | Description | -| :----- | :-------------------- | :------------- | :---------------- | -| GET | `/rfas` | `rfas.view` | รายการ RFA | +| Method | Endpoint | Permission | Description | +| :----- | :-------------------- | :------------- | :----------------- | +| GET | `/rfas` | `rfas.view` | รายการ RFA | | GET | `/rfas/:id` | `rfas.view` | รายละเอียด | -| POST | `/rfas` | `rfas.create` | สร้างใหม่ | +| POST | `/rfas` | `rfas.create` | สร้างใหม่ | | PUT | `/rfas/:id` | `rfas.update` | อัปเดต | -| DELETE | `/rfas/:id` | `rfas.delete` | ลบ | +| DELETE | `/rfas/:id` | `rfas.delete` | ลบ | | POST | `/rfas/:id/respond` | `rfas.respond` | ตอบกลับ RFA | -| POST | `/rfas/:id/approve` | `rfas.approve` | อนุมัติ RFA | +| POST | `/rfas/:id/approve` | `rfas.approve` | อนุมัติ RFA | | POST | `/rfas/:id/revisions` | `rfas.update` | สร้าง Revision | | GET | `/rfas/:id/workflow` | `rfas.view` | ดู Workflow Status | @@ -338,29 +337,29 @@ POST /api/v1/files/upload | Method | Endpoint | Permission | Description | | :----- | :----------------------------- | :---------------- | :------------------ | | GET | `/shop-drawings` | `drawings.view` | รายการ Shop Drawing | -| POST | `/shop-drawings` | `drawings.upload` | อัปโหลดใหม่ | -| GET | `/shop-drawings/:id/revisions` | `drawings.view` | ดู Revisions | +| POST | `/shop-drawings` | `drawings.upload` | อัปโหลดใหม่ | +| GET | `/shop-drawings/:id/revisions` | `drawings.view` | ดู Revisions | **Contract Drawings:** | Method | Endpoint | Permission | Description | | :----- | :------------------- | :---------------- | :---------------------- | | GET | `/contract-drawings` | `drawings.view` | รายการ Contract Drawing | -| POST | `/contract-drawings` | `drawings.upload` | อัปโหลดใหม่ | +| POST | `/contract-drawings` | `drawings.upload` | อัปโหลดใหม่ | ### 7.4 Project Module **Base Path:** `/api/v1/projects` -| Method | Endpoint | Permission | Description | -| :----- | :------------------------ | :----------------------- | :---------------- | -| GET | `/projects` | `projects.view` | รายการโครงการ | +| Method | Endpoint | Permission | Description | +| :----- | :------------------------ | :----------------------- | :----------------- | +| GET | `/projects` | `projects.view` | รายการโครงการ | | GET | `/projects/:id` | `projects.view` | รายละเอียด | -| POST | `/projects` | `projects.create` | สร้างโครงการใหม่ | +| POST | `/projects` | `projects.create` | สร้างโครงการใหม่ | | PUT | `/projects/:id` | `projects.update` | อัปเดต | | POST | `/projects/:id/contracts` | `contracts.create` | สร้าง Contract | | GET | `/projects/:id/parties` | `projects.view` | ดู Project Parties | -| POST | `/projects/:id/parties` | `project_parties.manage` | เพิ่ม Party | +| POST | `/projects/:id/parties` | `project_parties.manage` | เพิ่ม Party | ### 7.5 User & Auth Module diff --git a/specs/99-archives/02-02-network-infrastructure.md b/specs/99-archives/02-02-network-infrastructure.md index 92ff82f..bb2b9b2 100644 --- a/specs/99-archives/02-02-network-infrastructure.md +++ b/specs/99-archives/02-02-network-infrastructure.md @@ -8,13 +8,15 @@ status: first-draft owner: Nattanin Peancharoen last_updated: 2026-02-23 related: - - specs/02-Architecture/00-01-system-context.md + +- specs/02-Architecture/00-01-system-context.md --- ## 1. 🌐 Network Configuration Details ### 1.1 VLAN Networks + | VLAN ID | Name | Purpose | Gateway/Subnet | DHCP | IP Range | DNS | Lease Time | Notes | | ------- | ------ | --------- | --------------- | ---- | ------------------ | ------- | ---------- | --------------- | | 10 | SERVER | Interface | 192.168.10.1/24 | No | - | Custom | - | Static servers | @@ -26,6 +28,7 @@ related: | 70 | GUEST | Interface | 192.168.70.1/24 | Yes | 192.168.70.200-250 | Auto | 1 Day | Guest | ### 1.2 Switch Profiles + | Profile Name | Native Network | Tagged Networks | Untagged Networks | Usage | | ---------------- | -------------- | --------------------- | ----------------- | ----------------------- | | 01_CORE_TRUNK | MGMT (20) | 10,30,40,50,60,70 | MGMT (20) | Router & switch uplinks | @@ -37,6 +40,7 @@ related: | 07_VOICE_ACCESS | USER (30) | VOICE (50) | USER (30) | IP Phones | ### 1.3 NAS NIC Bonding Configuration + | Device | Bonding Mode | Member Ports | VLAN Mode | Tagged VLAN | IP Address | Gateway | Notes | | ------- | ------------------- | ------------ | --------- | ----------- | --------------- | ------------ | ---------------------- | | QNAP | IEEE 802.3ad (LACP) | Adapter 1, 2 | Untagged | 10 (SERVER) | 192.168.10.8/24 | 192.168.10.1 | Primary NAS for DMS | @@ -47,58 +51,61 @@ related: ## 2. 🛡️ Network ACLs & Firewall Rules ### 2.1 Gateway ACL (ER7206 Firewall Rules) -*Inter-VLAN Routing Policy* -| # | Name | Source | Destination | Service | Action | Log | Notes | -| --- | ----------------- | --------------- | ---------------- | -------------- | ------ | --- | --------------------------- | -| 1 | MGMT-to-ALL | VLAN20 (MGMT) | Any | Any | Allow | No | Admin full access | -| 2 | SERVER-to-ALL | VLAN10 (SERVER) | Any | Any | Allow | No | Servers outbound access | -| 3 | USER-to-SERVER | VLAN30 (USER) | VLAN10 (SERVER) | HTTP/HTTPS/SSH | Allow | No | Users access web apps | -| 4 | USER-to-DMZ | VLAN30 (USER) | VLAN60 (DMZ) | HTTP/HTTPS | Allow | No | Users access DMZ services | -| 5 | USER-to-MGMT | VLAN30 (USER) | VLAN20 (MGMT) | Any | Deny | Yes | Block users from management | -| 6 | USER-to-CCTV | VLAN30 (USER) | VLAN40 (CCTV) | Any | Deny | Yes | Isolate CCTV | -| 7 | USER-to-VOICE | VLAN30 (USER) | VLAN50 (VOICE) | Any | Deny | No | Isolate Voice | -| 8 | USER-to-GUEST | VLAN30 (USER) | VLAN70 (GUEST) | Any | Deny | No | Isolate Guest | -| 9 | CCTV-to-INTERNET | VLAN40 (CCTV) | WAN | HTTPS (443) | Allow | No | NVR cloud backup (optional) | -| 10 | CCTV-to-ALL | VLAN40 (CCTV) | Any (except WAN) | Any | Deny | Yes | CCTV isolated | -| 11 | DMZ-to-ALL | VLAN60 (DMZ) | Any (internal) | Any | Deny | Yes | DMZ cannot reach internal | -| 12 | GUEST-to-INTERNET | VLAN70 (GUEST) | WAN | HTTP/HTTPS/DNS | Allow | No | Guest internet only | -| 13 | GUEST-to-ALL | VLAN70 (GUEST) | Any (internal) | Any | Deny | Yes | Guest isolated | -| 99 | DEFAULT-DENY | Any | Any | Any | Deny | Yes | Catch-all deny | -*WAN Inbound Rules (Port Forwarding)* -| # | Name | WAN Port | Internal IP | Internal Port | Protocol | Notes | +_Inter-VLAN Routing Policy_ +| # | Name | Source | Destination | Service | Action | Log | Notes | +| --- | ----------------- | --------------- | ---------------- | -------------- | ------ | --- | --------------------------- | +| 1 | MGMT-to-ALL | VLAN20 (MGMT) | Any | Any | Allow | No | Admin full access | +| 2 | SERVER-to-ALL | VLAN10 (SERVER) | Any | Any | Allow | No | Servers outbound access | +| 3 | USER-to-SERVER | VLAN30 (USER) | VLAN10 (SERVER) | HTTP/HTTPS/SSH | Allow | No | Users access web apps | +| 4 | USER-to-DMZ | VLAN30 (USER) | VLAN60 (DMZ) | HTTP/HTTPS | Allow | No | Users access DMZ services | +| 5 | USER-to-MGMT | VLAN30 (USER) | VLAN20 (MGMT) | Any | Deny | Yes | Block users from management | +| 6 | USER-to-CCTV | VLAN30 (USER) | VLAN40 (CCTV) | Any | Deny | Yes | Isolate CCTV | +| 7 | USER-to-VOICE | VLAN30 (USER) | VLAN50 (VOICE) | Any | Deny | No | Isolate Voice | +| 8 | USER-to-GUEST | VLAN30 (USER) | VLAN70 (GUEST) | Any | Deny | No | Isolate Guest | +| 9 | CCTV-to-INTERNET | VLAN40 (CCTV) | WAN | HTTPS (443) | Allow | No | NVR cloud backup (optional) | +| 10 | CCTV-to-ALL | VLAN40 (CCTV) | Any (except WAN) | Any | Deny | Yes | CCTV isolated | +| 11 | DMZ-to-ALL | VLAN60 (DMZ) | Any (internal) | Any | Deny | Yes | DMZ cannot reach internal | +| 12 | GUEST-to-INTERNET | VLAN70 (GUEST) | WAN | HTTP/HTTPS/DNS | Allow | No | Guest internet only | +| 13 | GUEST-to-ALL | VLAN70 (GUEST) | Any (internal) | Any | Deny | Yes | Guest isolated | +| 99 | DEFAULT-DENY | Any | Any | Any | Deny | Yes | Catch-all deny | + +_WAN Inbound Rules (Port Forwarding)_ +| # | Name | WAN Port | Internal IP | Internal Port | Protocol | Notes | | --- | --------- | -------- | ------------ | ------------- | -------- | ------------------- | -| 1 | HTTPS-NPM | 443 | 192.168.10.8 | 443 | TCP | Nginx Proxy Manager | -| 2 | HTTP-NPM | 80 | 192.168.10.8 | 80 | TCP | HTTP redirect | +| 1 | HTTPS-NPM | 443 | 192.168.10.8 | 443 | TCP | Nginx Proxy Manager | +| 2 | HTTP-NPM | 80 | 192.168.10.8 | 80 | TCP | HTTP redirect | ### 2.2 Switch ACL (Layer 2 Rules) -*Port-Based Access Control* -| # | Name | Source Port | Source MAC/VLAN | Destination | Action | Notes | + +_Port-Based Access Control_ +| # | Name | Source Port | Source MAC/VLAN | Destination | Action | Notes | | --- | --------------- | --------------- | --------------- | ------------------- | ------ | ------------------------ | -| 1 | CCTV-Isolation | Port 25 (CCTV) | VLAN 40 | VLAN 10,20,30 | Deny | CCTV cannot reach others | -| 2 | Guest-Isolation | Port 5-20 (APs) | VLAN 70 | VLAN 10,20,30,40,50 | Deny | Guest isolation | +| 1 | CCTV-Isolation | Port 25 (CCTV) | VLAN 40 | VLAN 10,20,30 | Deny | CCTV cannot reach others | +| 2 | Guest-Isolation | Port 5-20 (APs) | VLAN 70 | VLAN 10,20,30,40,50 | Deny | Guest isolation | ### 2.3 EAP ACL (Wireless Rules) -*SSID: PSLCBP3 (Staff WiFi)* -| # | Name | Source | Destination | Service | Action | Notes | -| --- | ------------------- | ---------- | ---------------- | -------- | ------ | ----------------- | -| 1 | Allow-DNS | Any Client | 8.8.8.8, 1.1.1.1 | DNS (53) | Allow | DNS resolution | -| 2 | Allow-Server | Any Client | 192.168.10.0/24 | Any | Allow | Access to servers | -| 3 | Allow-Printer | Any Client | 192.168.30.222 | 9100,631 | Allow | Print services | -| 4 | Allow-Internet | Any Client | WAN | Any | Allow | Internet access | -| 5 | Block-MGMT | Any Client | 192.168.20.0/24 | Any | Deny | No management | -| 6 | Block-CCTV | Any Client | 192.168.40.0/24 | Any | Deny | No CCTV access | -| 8 | Block-Client2Client | Any Client | Any Client | Any | Deny | Client isolation | -*SSID: GUEST (Guest WiFi)* -| # | Name | Source | Destination | Service | Action | Notes | +_SSID: PSLCBP3 (Staff WiFi)_ +| # | Name | Source | Destination | Service | Action | Notes | +| --- | ------------------- | ---------- | ---------------- | -------- | ------ | ----------------- | +| 1 | Allow-DNS | Any Client | 8.8.8.8, 1.1.1.1 | DNS (53) | Allow | DNS resolution | +| 2 | Allow-Server | Any Client | 192.168.10.0/24 | Any | Allow | Access to servers | +| 3 | Allow-Printer | Any Client | 192.168.30.222 | 9100,631 | Allow | Print services | +| 4 | Allow-Internet | Any Client | WAN | Any | Allow | Internet access | +| 5 | Block-MGMT | Any Client | 192.168.20.0/24 | Any | Deny | No management | +| 6 | Block-CCTV | Any Client | 192.168.40.0/24 | Any | Deny | No CCTV access | +| 8 | Block-Client2Client | Any Client | Any Client | Any | Deny | Client isolation | + +_SSID: GUEST (Guest WiFi)_ +| # | Name | Source | Destination | Service | Action | Notes | | --- | ------------------- | ---------- | ---------------- | ---------- | ------ | ------------------ | -| 1 | Allow-DNS | Any Client | 8.8.8.8, 1.1.1.1 | DNS (53) | Allow | DNS resolution | -| 2 | Allow-HTTP | Any Client | WAN | HTTP/HTTPS | Allow | Web browsing | -| 3 | Block-RFC1918 | Any Client | 10.0.0.0/8 | Any | Deny | No private IPs | -| 4 | Block-RFC1918-2 | Any Client | 172.16.0.0/12 | Any | Deny | No private IPs | -| 5 | Block-RFC1918-3 | Any Client | 192.168.0.0/16 | Any | Deny | No internal access | -| 6 | Block-Client2Client | Any Client | Any Client | Any | Deny | Client isolation | +| 1 | Allow-DNS | Any Client | 8.8.8.8, 1.1.1.1 | DNS (53) | Allow | DNS resolution | +| 2 | Allow-HTTP | Any Client | WAN | HTTP/HTTPS | Allow | Web browsing | +| 3 | Block-RFC1918 | Any Client | 10.0.0.0/8 | Any | Deny | No private IPs | +| 4 | Block-RFC1918-2 | Any Client | 172.16.0.0/12 | Any | Deny | No private IPs | +| 5 | Block-RFC1918-3 | Any Client | 192.168.0.0/16 | Any | Deny | No internal access | +| 6 | Block-Client2Client | Any Client | Any Client | Any | Deny | Client isolation | ## 3. 📈 Network Topology Diagram diff --git a/specs/99-archives/02-03-data-model.md b/specs/99-archives/02-03-data-model.md index 20f533a..21004ec 100644 --- a/specs/99-archives/02-03-data-model.md +++ b/specs/99-archives/02-03-data-model.md @@ -542,9 +542,7 @@ PARTITION BY RANGE (YEAR(created_at)) ( // File: backend/src/migrations/1234567890-AddDisciplineToCorrespondences.ts import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddDisciplineToCorrespondences1234567890 - implements MigrationInterface -{ +export class AddDisciplineToCorrespondences1234567890 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE correspondences @@ -561,12 +559,8 @@ export class AddDisciplineToCorrespondences1234567890 } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE correspondences DROP FOREIGN KEY fk_corr_discipline` - ); - await queryRunner.query( - `ALTER TABLE correspondences DROP COLUMN discipline_id` - ); + await queryRunner.query(`ALTER TABLE correspondences DROP FOREIGN KEY fk_corr_discipline`); + await queryRunner.query(`ALTER TABLE correspondences DROP COLUMN discipline_id`); } } ``` diff --git a/specs/99-archives/02_Network_daigram.md b/specs/99-archives/02_Network_daigram.md index 2e33895..4f56441 100644 --- a/specs/99-archives/02_Network_daigram.md +++ b/specs/99-archives/02_Network_daigram.md @@ -244,6 +244,7 @@ graph TD > 📖 **ดูรายละเอียด Firewall ACLs และ Port Forwarding ได้ที่:** [03_Securities.md](03_Securities.md) ไฟล์ `03_Securities.md` ประกอบด้วย: + - 🌐 VLAN Segmentation - 🔥 Firewall Rules (IP Groups, Port Groups, Switch ACL, Gateway ACL) - 🚪 Port Forwarding Configuration diff --git a/specs/99-archives/03-04-document-numbering.md b/specs/99-archives/03-04-document-numbering.md index 0a115dd..95f0af5 100644 --- a/specs/99-archives/03-04-document-numbering.md +++ b/specs/99-archives/03-04-document-numbering.md @@ -1,20 +1,24 @@ # Document Numbering Implementation Guide (Combined) --- + title: 'Implementation Guide: Document Numbering System' version: 1.6.2 status: APPROVED owner: Development Team last_updated: 2025-12-17 related: - - specs/01-requirements/03.11-document-numbering.md - - specs/04-operations/document-numbering-operations.md - - specs/05-decisions/ADR-002-document-numbering-strategy.md + +- specs/01-requirements/03.11-document-numbering.md +- specs/04-operations/document-numbering-operations.md +- specs/05-decisions/ADR-002-document-numbering-strategy.md + --- ## Overview เอกสารนี้รวบรวม implementation details สำหรับระบบ Document Numbering โดยผนวกข้อมูลจาก: + - `document-numbering.md` - Core implementation และ database schema - `document-numbering-add.md` - Extended features (Reservation, Manual Override, Monitoring) @@ -219,6 +223,7 @@ CREATE TABLE document_number_errors ( INDEX idx_user_id (user_id) ) ENGINE=InnoDB COMMENT='Document Numbering Error Log'; ``` + ### 2.5 Reservation Table ```sql @@ -268,6 +273,7 @@ CREATE TABLE document_number_reservations ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Document Number Reservations - Two-Phase Commit'; ``` + --- ## 3. Core Services @@ -336,7 +342,7 @@ export class CounterService { constructor( @InjectRepository(DocumentNumberCounter) private counterRepo: Repository, - private dataSource: DataSource, + private dataSource: DataSource ) {} async incrementCounter(counterKey: CounterKey): Promise { @@ -420,9 +426,11 @@ export class DocumentNumberingLockService { } private buildLockKey(key: CounterKey): string { - return `lock:docnum:${key.projectId}:${key.originatorOrgId}:` + - `${key.recipientOrgId ?? 0}:${key.correspondenceTypeId}:` + - `${key.subTypeId}:${key.rfaTypeId}:${key.disciplineId}:${key.year}`; + return ( + `lock:docnum:${key.projectId}:${key.originatorOrgId}:` + + `${key.recipientOrgId ?? 0}:${key.correspondenceTypeId}:` + + `${key.subTypeId}:${key.rfaTypeId}:${key.disciplineId}:${key.year}` + ); } } ``` @@ -438,19 +446,12 @@ export class ReservationService { constructor( private redis: Redis, private sequenceService: SequenceService, - private auditService: AuditService, + private auditService: AuditService ) {} - async reserve( - documentType: string, - scopeValue?: string, - metadata?: Record, - ): Promise { + async reserve(documentType: string, scopeValue?: string, metadata?: Record): Promise { // 1. Generate next number - const documentNumber = await this.sequenceService.getNextSequence( - documentType, - scopeValue, - ); + const documentNumber = await this.sequenceService.getNextSequence(documentType, scopeValue); // 2. Generate reservation token const token = uuidv4(); @@ -466,11 +467,7 @@ export class ReservationService { metadata, }; - await this.redis.setex( - `reservation:${token}`, - this.TTL, - JSON.stringify(reservation), - ); + await this.redis.setex(`reservation:${token}`, this.TTL, JSON.stringify(reservation)); // 4. Audit log await this.auditService.log({ @@ -487,9 +484,7 @@ export class ReservationService { const reservation = await this.getReservation(token); if (!reservation) { - throw new ReservationExpiredError( - 'Reservation not found or expired. Please reserve a new number.', - ); + throw new ReservationExpiredError('Reservation not found or expired. Please reserve a new number.'); } await this.redis.del(`reservation:${token}`); @@ -561,8 +556,16 @@ export class ReservationService { @Injectable() export class TemplateValidator { private readonly ALLOWED_TOKENS = [ - 'PROJECT', 'ORIGINATOR', 'RECIPIENT', 'CORR_TYPE', - 'SUB_TYPE', 'RFA_TYPE', 'DISCIPLINE', 'SEQ', 'YEAR', 'REV', + 'PROJECT', + 'ORIGINATOR', + 'RECIPIENT', + 'CORR_TYPE', + 'SUB_TYPE', + 'RFA_TYPE', + 'DISCIPLINE', + 'SEQ', + 'YEAR', + 'REV', ]; validate(template: string, correspondenceType: string): ValidationResult { @@ -722,29 +725,30 @@ flowchart TD ## 8. Testing ### 8.1 Unit Tests + ```bash # Run unit tests pnpm test:watch -- --testPathPattern=document-numbering ``` ### 8.2 Integration Tests + ```bash # Run integration tests pnpm test:e2e -- --testPathPattern=numbering ``` ### 8.3 Concurrency Test + ```typescript // tests/load/concurrency.spec.ts it('should handle 1000 concurrent requests without duplicates', async () => { const promises = Array.from({ length: 1000 }, () => - request(app.getHttpServer()) - .post('/document-numbering/reserve') - .send({ document_type: 'COR' }) + request(app.getHttpServer()).post('/document-numbering/reserve').send({ document_type: 'COR' }) ); const results = await Promise.all(promises); - const numbers = results.map(r => r.body.data.document_number); + const numbers = results.map((r) => r.body.data.document_number); const uniqueNumbers = new Set(numbers); expect(uniqueNumbers.size).toBe(1000); @@ -756,6 +760,7 @@ it('should handle 1000 concurrent requests without duplicates', async () => { ## 9. Best Practices ### 9.1 DO's ✅ + - ✅ Always use two-phase commit (reserve + confirm) - ✅ Implement fallback to DB-only if Redis fails - ✅ Log every operation to audit trail @@ -767,6 +772,7 @@ it('should handle 1000 concurrent requests without duplicates', async () => { - ✅ Skip cancelled numbers (never reuse) ### 9.2 DON'Ts ❌ + - ❌ Never skip validation for manual override - ❌ Never reuse cancelled numbers - ❌ Never trust client-generated numbers diff --git a/specs/99-archives/03_Securities.md b/specs/99-archives/03_Securities.md index 43e73fa..c8c00de 100644 --- a/specs/99-archives/03_Securities.md +++ b/specs/99-archives/03_Securities.md @@ -10,39 +10,38 @@ ใน Omada Controller (OC200) ให้คุณไปที่ `Settings > Wired Networks > LAN` และสร้างเครือข่ายย่อย (VLANs) ดังนี้: -* **VLAN 10: SERVER** - * **IP Range:** 192.168.10.x - * **วัตถุประสงค์:** ใช้สำหรับอุปกรณ์ Server (QNAP และ ASUSTOR) +- **VLAN 10: SERVER** + - **IP Range:** 192.168.10.x + - **วัตถุประสงค์:** ใช้สำหรับอุปกรณ์ Server (QNAP และ ASUSTOR) -* **VLAN 20 MGMT(Default): Management** - * **IP Range:** 192.168.20.x - * **วัตถุประสงค์:** ใช้สำหรับอุปกรณ์ Network (ER7206, OC200, Switches) และ PC ของผู้ดูแลระบบ (Admin) เท่านั้น +- **VLAN 20 MGMT(Default): Management** + - **IP Range:** 192.168.20.x + - **วัตถุประสงค์:** ใช้สำหรับอุปกรณ์ Network (ER7206, OC200, Switches) และ PC ของผู้ดูแลระบบ (Admin) เท่านั้น -* **VLAN 30: USER** - * **IP Range:** 192.168.30.x - * **วัตถุประสงค์:** สำหรับ PC, Notebook, และ Wi-Fi ของพนักงานทั่วไปที่ต้องเข้าใช้งานระบบ (เช่น `lcbp3.np-dms.work`) +- **VLAN 30: USER** + - **IP Range:** 192.168.30.x + - **วัตถุประสงค์:** สำหรับ PC, Notebook, และ Wi-Fi ของพนักงานทั่วไปที่ต้องเข้าใช้งานระบบ (เช่น `lcbp3.np-dms.work`) -* **VLAN 40: CCTV** - * **IP Range:** 192.168.40.x - * **วัตถุประสงค์:** ใช้สำหรับอุปกรณ์ CCTV เท่านั้น +- **VLAN 40: CCTV** + - **IP Range:** 192.168.40.x + - **วัตถุประสงค์:** ใช้สำหรับอุปกรณ์ CCTV เท่านั้น -* **VLAN 50 VOICEt** - * **IP Range:** 192.168.50.x - * **วัตถุประสงค์:** ใช้สำหรับอุปกรณ์ IP Phone เท่านั้น +- **VLAN 50 VOICEt** + - **IP Range:** 192.168.50.x + - **วัตถุประสงค์:** ใช้สำหรับอุปกรณ์ IP Phone เท่านั้น -* **VLAN 60 DMZ** - * **IP Range:** 192.168.60.x - * **วัตถุประสงค์:** ใช้สำหรับ Network DMZ เท่านั้น - -* **VLAN 70: GUEST / Untrusted** (สำหรับ Wi-Fi แขก) - * **IP Range:** 192.168.70.x - * **วัตถุประสงค์:** สำหรับ Wi-Fi แขก (Guest) ห้ามเข้าถึงเครือข่ายภายในโดยเด็ดขาด +- **VLAN 60 DMZ** + - **IP Range:** 192.168.60.x + - **วัตถุประสงค์:** ใช้สำหรับ Network DMZ เท่านั้น +- **VLAN 70: GUEST / Untrusted** (สำหรับ Wi-Fi แขก) + - **IP Range:** 192.168.70.x + - **วัตถุประสงค์:** สำหรับ Wi-Fi แขก (Guest) ห้ามเข้าถึงเครือข่ายภายในโดยเด็ดขาด **การตั้งค่า Port Switch:** หลังจากสร้าง VLANs แล้ว ให้ไปที่ `Devices` > เลือก Switch ของคุณ > `Ports` > กำหนด Port Profile: -* Port ที่เสียบ QNAP NAS: ตั้งค่า Profile เป็น **VLAN 10** -* Port ที่เสียบ PC พนักงาน: ตั้งค่า Profile เป็น **VLAN 20** +- Port ที่เสียบ QNAP NAS: ตั้งค่า Profile เป็น **VLAN 10** +- Port ที่เสียบ PC พนักงาน: ตั้งค่า Profile เป็น **VLAN 20** --- @@ -61,7 +60,7 @@ | Group Name | Members | | :----------------- | :------------------------------------------------ | | `Server` | 192.168.10.8, 192.168.10.9, 192.168.10.111 | -| `Omada-Controller` | 192.168.20.250 (OC200 IP) | +| `Omada-Controller` | 192.168.20.250 (OC200 IP) | | `DHCP-Gateways` | 192.168.30.1, 192.168.70.1 | | `QNAP_Services` | 192.168.10.8 | | `Internal` | 192.168.10.0/24, 192.168.20.0/24, 192.168.30.0/24 | @@ -80,23 +79,24 @@ ### 2.2 Switch ACL (สำหรับ Omada OC200) -| ลำดับ | Name | Policy | Source | Destination | Ports | -| :--- | :------------------------ | :----- | :---------------- | :---------------------------- | :---------------------------------------- | -| 1 | 01 Allow-User-DHCP | Allow | Network → VLAN 30 | IP → 192.168.30.1 | Port Group → DHCP | -| 2 | 02 Allow-Guest-DHCP | Allow | Network → VLAN 70 | IP → 192.168.70.1 | Port Group → DHCP | -| 3 | 03 Allow-WiFi-Auth | Allow | Network → VLAN 30 | IP Group → Omada-Controller | Port Group → Omada-Auth | -| 4 | 04 Allow-Guest-WiFi-Auth | Allow | Network → VLAN 70 | IP Group → Omada-Controller | Port Group → Omada-Auth | -| 5 | 05 Isolate-Guests | Deny | Network → VLAN 70 | Network → VLAN 10, 20, 30, 60 | All | -| 6 | 06 Isolate-Servers | Deny | Network → VLAN 10 | Network → VLAN 30 (USER) | All | -| 7 | 07 Block-User-to-Mgmt | Deny | Network → VLAN 30 | Network → VLAN 20 (MGMT) | All | -| 8 | 08 Allow-User-to-Services | Allow | Network → VLAN 30 | IP → QNAP (192.168.10.8) | Port Group → Web (443,8443, 80, 81, 2222) | -| 9 | 09 Allow-Voice-to-User | Allow | Network → VLAN 50 | Network → VLAN 30,50 | All | -| 10 | 10 Allow-MGMT-to-All | Allow | Network → VLAN 20 | Any | All | -| 11 | 11 Allow-Server-Internal | Allow | IP Group : Server | IP Group : Server | All | -| 12 | 12 Allow-Server → CCTV | Allow | IP Group : Server | Network → VLAN 40 (CCTV) | All | -| 13 | 100 (Default) | Deny | Any | Any | All | +| ลำดับ | Name | Policy | Source | Destination | Ports | +| :---- | :------------------------ | :----- | :---------------- | :---------------------------- | :---------------------------------------- | +| 1 | 01 Allow-User-DHCP | Allow | Network → VLAN 30 | IP → 192.168.30.1 | Port Group → DHCP | +| 2 | 02 Allow-Guest-DHCP | Allow | Network → VLAN 70 | IP → 192.168.70.1 | Port Group → DHCP | +| 3 | 03 Allow-WiFi-Auth | Allow | Network → VLAN 30 | IP Group → Omada-Controller | Port Group → Omada-Auth | +| 4 | 04 Allow-Guest-WiFi-Auth | Allow | Network → VLAN 70 | IP Group → Omada-Controller | Port Group → Omada-Auth | +| 5 | 05 Isolate-Guests | Deny | Network → VLAN 70 | Network → VLAN 10, 20, 30, 60 | All | +| 6 | 06 Isolate-Servers | Deny | Network → VLAN 10 | Network → VLAN 30 (USER) | All | +| 7 | 07 Block-User-to-Mgmt | Deny | Network → VLAN 30 | Network → VLAN 20 (MGMT) | All | +| 8 | 08 Allow-User-to-Services | Allow | Network → VLAN 30 | IP → QNAP (192.168.10.8) | Port Group → Web (443,8443, 80, 81, 2222) | +| 9 | 09 Allow-Voice-to-User | Allow | Network → VLAN 50 | Network → VLAN 30,50 | All | +| 10 | 10 Allow-MGMT-to-All | Allow | Network → VLAN 20 | Any | All | +| 11 | 11 Allow-Server-Internal | Allow | IP Group : Server | IP Group : Server | All | +| 12 | 12 Allow-Server → CCTV | Allow | IP Group : Server | Network → VLAN 40 (CCTV) | All | +| 13 | 100 (Default) | Deny | Any | Any | All | > ⚠️ **หมายเหตุสำคัญ - ลำดับ ACL:** +> > 1. **Allow rules ก่อน** - DHCP (#1-2) และ WiFi-Auth (#3-4) ต้องอยู่ **บนสุด** > 2. **Isolate/Deny rules ถัดมา** - (#5-7) block traffic ที่ไม่ต้องการ > 3. **Allow specific rules** - (#8-12) อนุญาต traffic ที่เหลือ @@ -106,12 +106,12 @@ ### 2.3 Gateway ACL (สำหรับ Omada ER7206) -| ลำดับ | Name | Policy | Direction | PROTOCOLS | Source | Destination | -| :--- | :---------------------- | :----- | :-------- | :-------- | :------------------- | :--------------------------- | -| 1 | 01 Blacklist | Deny | [WAN2] IN | All | IP Group:Blacklist | IP Group:Internal | -| 2 | 02 Geo | Permit | [WAN2] IN | All | Location Group:Allow | IP Group:Internal | -| 3 | 03 Allow-Voice-Internet | Permit | LAN->WAN | UDP | Network → VLAN 50 | Any | -| 4 | 04 Internal → Internet | Permit | LAN->WAN | All | IP Group:Internal | Domain Group:DomainGroup_Any | +| ลำดับ | Name | Policy | Direction | PROTOCOLS | Source | Destination | +| :---- | :---------------------- | :----- | :-------- | :-------- | :------------------- | :--------------------------- | +| 1 | 01 Blacklist | Deny | [WAN2] IN | All | IP Group:Blacklist | IP Group:Internal | +| 2 | 02 Geo | Permit | [WAN2] IN | All | Location Group:Allow | IP Group:Internal | +| 3 | 03 Allow-Voice-Internet | Permit | LAN->WAN | UDP | Network → VLAN 50 | Any | +| 4 | 04 Internal → Internet | Permit | LAN->WAN | All | IP Group:Internal | Domain Group:DomainGroup_Any | > 💡 **หมายเหตุ:** Rule #3 `Allow-Voice-Internet` อนุญาต IP Phone (VLAN 50) เชื่อมต่อ Cloud PBX ภายนอก ผ่าน Port Group → VoIP (UDP 5060, 5061, 10000-20000) @@ -124,17 +124,17 @@ สร้างกฎเพื่อส่งต่อการจราจรจาก WAN (อินเทอร์เน็ต) ไปยัง Nginx Proxy Manager (NPM) ที่อยู่บน QNAP (VLAN 10) -* **Name:** Allow-NPM-HTTPS -* **External Port:** 443 -* **Internal Port:** 443 -* **Internal IP:** `192.168.10.8` (IP ของ QNAP) -* **Protocol:** TCP +- **Name:** Allow-NPM-HTTPS +- **External Port:** 443 +- **Internal Port:** 443 +- **Internal IP:** `192.168.10.8` (IP ของ QNAP) +- **Protocol:** TCP -* **Name:** Allow-NPM-HTTP (สำหรับ Let's Encrypt) -* **External Port:** 80 -* **Internal Port:** 80 -* **Internal IP:** `192.168.10.8` (IP ของ QNAP) -* **Protocol:** TCP +- **Name:** Allow-NPM-HTTP (สำหรับ Let's Encrypt) +- **External Port:** 80 +- **Internal Port:** 80 +- **Internal IP:** `192.168.10.8` (IP ของ QNAP) +- **Protocol:** TCP ### สรุปผังการเชื่อมต่อ diff --git a/specs/99-archives/04-01-deployment-guide.md b/specs/99-archives/04-01-deployment-guide.md index 44f6a7d..6c2a5a0 100644 --- a/specs/99-archives/04-01-deployment-guide.md +++ b/specs/99-archives/04-01-deployment-guide.md @@ -319,7 +319,7 @@ services: - discovery.type=single-node - xpack.security.enabled=true - ELASTIC_PASSWORD=${ELASTICSEARCH_PASSWORD} - - "ES_JAVA_OPTS=-Xms2g -Xmx2g" + - 'ES_JAVA_OPTS=-Xms2g -Xmx2g' volumes: - /volume1/lcbp3/volumes/elastic-data:/usr/share/elasticsearch/data networks: @@ -348,8 +348,8 @@ services: container_name: lcbp3-nginx restart: unless-stopped ports: - - "80:80" - - "443:443" + - '80:80' + - '443:443' volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./ssl:/etc/nginx/ssl:ro diff --git a/specs/99-archives/04-02-environment-setup.md b/specs/99-archives/04-02-environment-setup.md index bbfe9cd..a8003bf 100644 --- a/specs/99-archives/04-02-environment-setup.md +++ b/specs/99-archives/04-02-environment-setup.md @@ -378,9 +378,7 @@ Backend validates environment variables at startup: import * as Joi from 'joi'; export const envValidationSchema = Joi.object({ - NODE_ENV: Joi.string() - .valid('development', 'staging', 'production') - .required(), + NODE_ENV: Joi.string().valid('development', 'staging', 'production').required(), DB_HOST: Joi.string().required(), DB_PORT: Joi.number().default(3306), DB_USER: Joi.string().required(), diff --git a/specs/99-archives/04-03-monitoring-alerting.md b/specs/99-archives/04-03-monitoring-alerting.md index 4a54e76..efae22f 100644 --- a/specs/99-archives/04-03-monitoring-alerting.md +++ b/specs/99-archives/04-03-monitoring-alerting.md @@ -60,12 +60,7 @@ This document describes monitoring setup, health checks, and alerting rules for ```typescript // File: backend/src/health/health.controller.ts import { Controller, Get } from '@nestjs/common'; -import { - HealthCheck, - HealthCheckService, - TypeOrmHealthIndicator, - DiskHealthIndicator, -} from '@nestjs/terminus'; +import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator, DiskHealthIndicator } from '@nestjs/terminus'; @Controller('health') export class HealthController { @@ -190,12 +185,7 @@ done ```typescript // File: backend/src/common/interceptors/performance.interceptor.ts -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { logger } from 'src/config/logger.config'; diff --git a/specs/99-archives/04-05-maintenance-procedures.md b/specs/99-archives/04-05-maintenance-procedures.md index 5e9f8ec..a658a17 100644 --- a/specs/99-archives/04-05-maintenance-procedures.md +++ b/specs/99-archives/04-05-maintenance-procedures.md @@ -382,10 +382,7 @@ docker exec lcbp3-redis redis-cli FLUSHDB async function bootstrap() { const app = await NestFactory.create(AppModule, { - logger: - process.env.NODE_ENV === 'production' - ? ['error', 'warn'] - : ['log', 'error', 'warn', 'debug'], + logger: process.env.NODE_ENV === 'production' ? ['error', 'warn'] : ['log', 'error', 'warn', 'debug'], }); // Enable compression @@ -465,18 +462,15 @@ echo "Security maintenance completed: $(date)" ### Unplanned Maintenance Procedures 1. **Assess Urgency** - - Can it wait for scheduled maintenance? - Is it causing active issues? 2. **Communicate Impact** - - Notify stakeholders immediately - Estimate downtime - Provide updates every 30 minutes 3. **Execute Carefully** - - Always backup first - Have rollback plan ready - Test in staging if possible diff --git a/specs/99-archives/04-06-security-operations.md b/specs/99-archives/04-06-security-operations.md index 20b6230..264be40 100644 --- a/specs/99-archives/04-06-security-operations.md +++ b/specs/99-archives/04-06-security-operations.md @@ -313,7 +313,6 @@ FLUSH PRIVILEGES; ``` 3. **Notify stakeholders** - - Security officer - Management - Affected users (if applicable) diff --git a/specs/99-archives/04-07-incident-response.md b/specs/99-archives/04-07-incident-response.md index 9153704..ced1264 100644 --- a/specs/99-archives/04-07-incident-response.md +++ b/specs/99-archives/04-07-incident-response.md @@ -401,22 +401,18 @@ Database connection pool was exhausted due to slow queries not releasing connect ### PIR Meeting Agenda 1. **Timeline Review** (10 min) - - What happened and when? - What was the impact? 2. **Root Cause Analysis** (15 min) - - Why did it happen? - What were the contributing factors? 3. **What Went Well** (10 min) - - What did we do right? - What helped us resolve quickly? 4. **What Went Wrong** (15 min) - - What could we have done better? - What slowed us down? diff --git a/specs/99-archives/04-08-document-numbering-operations.md b/specs/99-archives/04-08-document-numbering-operations.md index 1d6f3b1..98eacf3 100644 --- a/specs/99-archives/04-08-document-numbering-operations.md +++ b/specs/99-archives/04-08-document-numbering-operations.md @@ -1,16 +1,19 @@ # Document Numbering Operations Guide --- + title: 'Document Numbering Operations Guide' version: 1.7.0 status: APPROVED owner: Operations Team last_updated: 2025-12-18 related: - - specs/01-requirements/03.11-document-numbering.md - - specs/03-implementation/03-08-document-numbering.md - - specs/04-operations/04-08-monitoring-alerting.md - - specs/05-decisions/ADR-002-document-numbering-strategy.md + +- specs/01-requirements/03.11-document-numbering.md +- specs/03-implementation/03-08-document-numbering.md +- specs/04-operations/04-08-monitoring-alerting.md +- specs/05-decisions/ADR-002-document-numbering-strategy.md + --- ## Overview @@ -21,18 +24,18 @@ related: ### 1.1. Response Time Targets -| Metric | Target | Measurement | -| ---------------- | -------- | ------------------------ | +| Metric | Target | Measurement | +| ---------------- | ---------- | ---------------------------- | | 95th percentile | ≤ 2 วินาที | ตั้งแต่ request ถึง response | | 99th percentile | ≤ 5 วินาที | ตั้งแต่ request ถึง response | -| Normal operation | ≤ 500ms | ไม่มี retry | +| Normal operation | ≤ 500ms | ไม่มี retry | ### 1.2. Throughput Targets | Load Level | Target | Notes | | -------------- | ----------- | ------------------------ | -| Normal load | ≥ 50 req/s | ใช้งานปกติ | -| Peak load | ≥ 100 req/s | ช่วงเร่งงาน | +| Normal load | ≥ 50 req/s | ใช้งานปกติ | +| Peak load | ≥ 100 req/s | ช่วงเร่งงาน | | Burst capacity | ≥ 200 req/s | Short duration (< 1 min) | ### 1.3. Availability SLA @@ -280,9 +283,9 @@ groups: severity: critical component: document-numbering annotations: - summary: "Redis is unavailable for document numbering" - description: "System is falling back to DB-only locking. Performance degraded by 30-50%." - runbook_url: "https://wiki.lcbp3/runbooks/redis-unavailable" + summary: 'Redis is unavailable for document numbering' + description: 'System is falling back to DB-only locking. Performance degraded by 30-50%.' + runbook_url: 'https://wiki.lcbp3/runbooks/redis-unavailable' # CRITICAL: High lock failure rate - alert: HighLockFailureRate @@ -293,9 +296,9 @@ groups: severity: critical component: document-numbering annotations: - summary: "Lock acquisition failure rate > 10%" - description: "Check Redis and database performance immediately" - runbook_url: "https://wiki.lcbp3/runbooks/high-lock-failure" + summary: 'Lock acquisition failure rate > 10%' + description: 'Check Redis and database performance immediately' + runbook_url: 'https://wiki.lcbp3/runbooks/high-lock-failure' # WARNING: Elevated lock failure rate - alert: ElevatedLockFailureRate @@ -306,8 +309,8 @@ groups: severity: warning component: document-numbering annotations: - summary: "Lock acquisition failure rate > 5%" - description: "Monitor closely. May escalate to critical soon." + summary: 'Lock acquisition failure rate > 5%' + description: 'Monitor closely. May escalate to critical soon.' # WARNING: Slow lock acquisition - alert: SlowLockAcquisition @@ -320,8 +323,8 @@ groups: severity: warning component: document-numbering annotations: - summary: "P95 lock acquisition time > 1 second" - description: "Lock acquisition is slower than expected. Check Redis latency." + summary: 'P95 lock acquisition time > 1 second' + description: 'Lock acquisition is slower than expected. Check Redis latency.' # WARNING: High retry count - alert: HighRetryCount @@ -334,8 +337,8 @@ groups: severity: warning component: document-numbering annotations: - summary: "Retry count > 100 per hour in project {{ $labels.project }}" - description: "High contention detected. Consider scaling." + summary: 'Retry count > 100 per hour in project {{ $labels.project }}' + description: 'High contention detected. Consider scaling.' # WARNING: Slow generation - alert: SlowDocumentNumberGeneration @@ -348,8 +351,8 @@ groups: severity: warning component: document-numbering annotations: - summary: "P95 generation time > 2 seconds" - description: "Document number generation is slower than SLA target" + summary: 'P95 generation time > 2 seconds' + description: 'Document number generation is slower than SLA target' ``` ### 3.3. AlertManager Configuration @@ -450,6 +453,7 @@ Dashboard panels ที่สำคัญ: ### 4.1. Scenario: Redis Unavailable **Symptoms:** + - Alert: `RedisUnavailable` - System falls back to DB-only locking - Performance degraded 30-50% @@ -457,22 +461,26 @@ Dashboard panels ที่สำคัญ: **Action Steps:** 1. **Check Redis status:** + ```bash docker exec lcbp3-redis redis-cli ping # Expected: PONG ``` 2. **Check Redis logs:** + ```bash docker logs lcbp3-redis --tail=100 ``` 3. **Restart Redis (if needed):** + ```bash docker restart lcbp3-redis ``` 4. **Verify failover (if using Sentinel):** + ```bash docker exec lcbp3-redis-sentinel redis-cli -p 26379 SENTINEL masters ``` @@ -484,29 +492,34 @@ Dashboard panels ที่สำคัญ: ### 4.2. Scenario: High Lock Failure Rate **Symptoms:** + - Alert: `HighLockFailureRate` (> 10%) - Users report "ระบบกำลังยุ่ง" errors **Action Steps:** 1. **Check concurrent load:** + ```bash # Check current request rate curl http://prometheus:9090/api/v1/query?query=rate(docnum_generation_duration_ms_count[1m]) ``` 2. **Check database connections:** + ```sql SHOW PROCESSLIST; -- Look for waiting/locked queries ``` 3. **Check Redis memory:** + ```bash docker exec lcbp3-redis redis-cli INFO memory ``` 4. **Scale up if needed:** + ```bash # Increase backend replicas docker-compose up -d --scale backend=5 @@ -521,12 +534,14 @@ Dashboard panels ที่สำคัญ: ### 4.3. Scenario: Slow Performance **Symptoms:** + - Alert: `SlowDocumentNumberGeneration` - P95 > 2 seconds **Action Steps:** 1. **Check database query performance:** + ```sql SELECT * FROM document_number_counters USE INDEX (idx_counter_lookup) WHERE project_id = 2 AND correspondence_type_id = 6 AND current_year = 2025; @@ -536,16 +551,19 @@ Dashboard panels ที่สำคัญ: ``` 2. **Check for missing indexes:** + ```sql SHOW INDEX FROM document_number_counters; ``` 3. **Check Redis latency:** + ```bash docker exec lcbp3-redis redis-cli --latency ``` 4. **Check network latency:** + ```bash ping mariadb-master ping redis-master @@ -559,12 +577,14 @@ Dashboard panels ที่สำคัญ: ### 4.4. Scenario: Version Conflicts **Symptoms:** + - High retry count - Users report "เลขที่เอกสารถูกเปลี่ยน" errors **Action Steps:** 1. **Check concurrent requests to same counter:** + ```sql SELECT project_id, @@ -578,6 +598,7 @@ Dashboard panels ที่สำคัญ: ``` 2. **Investigate specific counter:** + ```sql SELECT * FROM document_number_counters WHERE project_id = X AND correspondence_type_id = Y; @@ -606,6 +627,7 @@ Dashboard panels ที่สำคัญ: **Steps:** 1. **Request approval via API:** + ```bash POST /api/v1/document-numbering/configs/{configId}/reset-counter { @@ -633,6 +655,7 @@ Dashboard panels ที่สำคัญ: 4. Template changes do NOT affect existing documents **API Call:** + ```bash PUT /api/v1/document-numbering/configs/{configId} { @@ -644,6 +667,7 @@ PUT /api/v1/document-numbering/configs/{configId} ### 5.3. Database Maintenance **Weekly Tasks:** + - Check slow query log - Optimize tables if needed: ```sql @@ -652,6 +676,7 @@ PUT /api/v1/document-numbering/configs/{configId} ``` **Monthly Tasks:** + - Review and archive old audit logs (> 2 years) - Check index usage: ```sql @@ -664,11 +689,13 @@ PUT /api/v1/document-numbering/configs/{configId} ### 6.1. Backup Strategy **Database:** + - Full backup: Daily at 02:00 AM - Incremental backup: Every 4 hours - Retention: 30 days **Redis:** + - AOF (Append-Only File) enabled - Snapshot every 1 hour - Retention: 7 days diff --git a/specs/99-archives/04-operations-README.md b/specs/99-archives/04-operations-README.md index 349691e..145d82a 100644 --- a/specs/99-archives/04-operations-README.md +++ b/specs/99-archives/04-operations-README.md @@ -16,23 +16,23 @@ This directory contains operational documentation for deploying, maintaining, an ### Deployment & Infrastructure -| Document | Description | Status | -| ---------------------------------------------- | ------------------------------------------------------ | ---------- | +| Document | Description | Status | +| -------------------------------------------------- | ------------------------------------------------------ | ----------- | | [deployment-guide.md](04-01-deployment-guide.md) | Docker deployment procedures on QNAP Container Station | ✅ Complete | | [environment-setup.md](04-02-environment-setup.md) | Environment variables and configuration management | ✅ Complete | ### Monitoring & Maintenance -| Document | Description | Status | -| -------------------------------------------------------- | --------------------------------------------------- | ---------- | +| Document | Description | Status | +| ------------------------------------------------------------ | --------------------------------------------------- | ----------- | | [monitoring-alerting.md](04-03-monitoring-alerting.md) | Monitoring setup, health checks, and alerting rules | ✅ Complete | | [backup-recovery.md](04-04-backup-recovery.md) | Backup strategies and disaster recovery procedures | ✅ Complete | | [maintenance-procedures.md](04-05-maintenance-procedures.md) | Routine maintenance and update procedures | ✅ Complete | ### Security & Compliance -| Document | Description | Status | -| -------------------------------------------------- | ---------------------------------------------- | ---------- | +| Document | Description | Status | +| ------------------------------------------------------ | ---------------------------------------------- | ----------- | | [security-operations.md](04-06-security-operations.md) | Security monitoring and incident response | ✅ Complete | | [incident-response.md](04-07-incident-response.md) | Incident classification and response playbooks | ✅ Complete | diff --git a/specs/99-archives/05_monitoring.md b/specs/99-archives/05_monitoring.md index 9b391e0..1f97f15 100644 --- a/specs/99-archives/05_monitoring.md +++ b/specs/99-archives/05_monitoring.md @@ -7,15 +7,15 @@ Stack สำหรับ Monitoring ประกอบด้วย: -| Service | Port | Purpose | Host | -| :---------------- | :--------------------------- | :-------------------------------- | :------ | +| Service | Port | Purpose | Host | +| :---------------- | :--------------------------- | :--------------------------------- | :------ | | **Prometheus** | 9090 | เก็บ Metrics และ Time-series data | ASUSTOR | -| **Grafana** | 3000 | Dashboard สำหรับแสดงผล Metrics | ASUSTOR | +| **Grafana** | 3000 | Dashboard สำหรับแสดงผล Metrics | ASUSTOR | | **Node Exporter** | 9100 | เก็บ Metrics ของ Host system | Both | | **cAdvisor** | 8080 (ASUSTOR) / 8088 (QNAP) | เก็บ Metrics ของ Docker containers | Both | -| **Uptime Kuma** | 3001 | Service Availability Monitoring | ASUSTOR | -| **Loki** | 3100 | Log aggregation | ASUSTOR | -| **Promtail** | - | Log shipper (Sender) | ASUSTOR | +| **Uptime Kuma** | 3001 | Service Availability Monitoring | ASUSTOR | +| **Loki** | 3100 | Log aggregation | ASUSTOR | +| **Promtail** | - | Log shipper (Sender) | ASUSTOR | --- @@ -148,10 +148,10 @@ x-restart: &restart_policy x-logging: &default_logging logging: - driver: "json-file" + driver: 'json-file' options: - max-size: "10m" - max-file: "5" + max-size: '10m' + max-file: '5' networks: lcbp3: @@ -170,27 +170,27 @@ services: deploy: resources: limits: - cpus: "1.0" + cpus: '1.0' memory: 1G reservations: - cpus: "0.25" + cpus: '0.25' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--storage.tsdb.retention.time=30d' - '--web.enable-lifecycle' ports: - - "9090:9090" + - '9090:9090' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/prometheus/config:/etc/prometheus:ro" - - "/volume1/np-dms/monitoring/prometheus/data:/prometheus" + - '/volume1/np-dms/monitoring/prometheus/config:/etc/prometheus:ro' + - '/volume1/np-dms/monitoring/prometheus/data:/prometheus' healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/healthy"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:9090/-/healthy'] interval: 30s timeout: 10s retries: 3 @@ -207,27 +207,27 @@ services: deploy: resources: limits: - cpus: "1.0" + cpus: '1.0' memory: 512M reservations: - cpus: "0.25" + cpus: '0.25' memory: 128M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' GF_SECURITY_ADMIN_USER: admin - GF_SECURITY_ADMIN_PASSWORD: "Center#2025" - GF_SERVER_ROOT_URL: "https://grafana.np-dms.work" + GF_SECURITY_ADMIN_PASSWORD: 'Center#2025' + GF_SERVER_ROOT_URL: 'https://grafana.np-dms.work' GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-piechart-panel ports: - - "3000:3000" + - '3000:3000' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/grafana/data:/var/lib/grafana" + - '/volume1/np-dms/monitoring/grafana/data:/var/lib/grafana' depends_on: - prometheus healthcheck: - test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/api/health || exit 1"] + test: ['CMD-SHELL', 'wget --spider -q http://localhost:3000/api/health || exit 1'] interval: 30s timeout: 10s retries: 3 @@ -242,18 +242,18 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' ports: - - "3001:3001" + - '3001:3001' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/uptime-kuma/data:/app/data" + - '/volume1/np-dms/monitoring/uptime-kuma/data:/app/data' healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3001/api/entry-page || exit 1"] + test: ['CMD-SHELL', 'curl -f http://localhost:3001/api/entry-page || exit 1'] interval: 30s timeout: 10s retries: 3 @@ -268,16 +268,16 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 128M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: - '--path.procfs=/host/proc' - '--path.sysfs=/host/sys' - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' ports: - - "9100:9100" + - '9100:9100' networks: - lcbp3 volumes: @@ -285,7 +285,7 @@ services: - /sys:/host/sys:ro - /:/rootfs:ro healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:9100/metrics"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:9100/metrics'] interval: 30s timeout: 10s retries: 3 @@ -303,12 +303,12 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' ports: - - "8088:8088" + - '8088:8088' networks: - lcbp3 volumes: @@ -318,7 +318,7 @@ services: - /var/lib/docker/:/var/lib/docker:ro - /dev/disk/:/dev/disk:ro healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:8080/healthz'] interval: 30s timeout: 10s retries: 3 @@ -333,19 +333,19 @@ services: deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 512M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: -config.file=/etc/loki/local-config.yaml ports: - - "3100:3100" + - '3100:3100' networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/loki/data:/loki" + - '/volume1/np-dms/monitoring/loki/data:/loki' healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:3100/ready"] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3100/ready'] interval: 30s timeout: 10s retries: 3 @@ -357,21 +357,21 @@ services: <<: [*restart_policy, *default_logging] image: grafana/promtail:2.9.0 container_name: promtail - user: "0:0" + user: '0:0' deploy: resources: limits: - cpus: "0.5" + cpus: '0.5' memory: 256M environment: - TZ: "Asia/Bangkok" + TZ: 'Asia/Bangkok' command: -config.file=/etc/promtail/promtail-config.yml networks: - lcbp3 volumes: - - "/volume1/np-dms/monitoring/promtail/config:/etc/promtail:ro" - - "/var/run/docker.sock:/var/run/docker.sock:ro" - - "/var/lib/docker/containers:/var/lib/docker/containers:ro" + - '/volume1/np-dms/monitoring/promtail/config:/etc/promtail:ro' + - '/var/run/docker.sock:/var/run/docker.sock:ro' + - '/var/lib/docker/containers:/var/lib/docker/containers:ro' depends_on: - loki ``` @@ -402,7 +402,7 @@ services: - '--path.sysfs=/host/sys' - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' ports: - - "9100:9100" + - '9100:9100' networks: - lcbp3 volumes: @@ -416,7 +416,7 @@ services: restart: unless-stopped privileged: true ports: - - "8088:8080" + - '8088:8080' networks: - lcbp3 volumes: @@ -434,11 +434,11 @@ services: command: - '--config.my-cnf=/etc/mysql/my.cnf' ports: - - "9104:9104" + - '9104:9104' networks: - lcbp3 volumes: - - "/share/np-dms/monitoring/mysqld-exporter/.my.cnf:/etc/mysql/my.cnf:ro" + - '/share/np-dms/monitoring/mysqld-exporter/.my.cnf:/etc/mysql/my.cnf:ro' ``` --- @@ -547,7 +547,6 @@ scrape_configs: | 14204 | Elasticsearch | Elasticsearch view | | 13106 | MySQL/MariaDB Overview | Detailed MySQL/MariaDB metrics | - ### Import Dashboard via Grafana UI 1. Go to **Dashboards → Import** @@ -561,13 +560,13 @@ scrape_configs: ### 📋 Prerequisites Checklist -| # | ขั้นตอน | Status | -| :--- | :------------------------------------------------------------------------------------------------- | :----- | -| 1 | SSH เข้า ASUSTOR ได้ (`ssh admin@192.168.10.9`) | ✅ | -| 2 | Docker Network `lcbp3` สร้างแล้ว (ดูหัวข้อ [สร้าง Docker Network](#-สร้าง-docker-network-ทำครั้งแรกครั้งเดียว)) | ✅ | -| 3 | สร้าง Directories และกำหนดสิทธิ์แล้ว (ดูหัวข้อ [กำหนดสิทธิ](#กำหนดสิทธิ-บน-asustor)) | ✅ | -| 4 | สร้าง `prometheus.yml` แล้ว (ดูหัวข้อ [Prometheus Configuration](#prometheus-configuration)) | ✅ | -| 5 | สร้าง `promtail-config.yml` แล้ว (ดูหัวข้อ [Step 1.2](#step-12-สร้าง-promtail-configyml)) | ✅ | +| # | ขั้นตอน | Status | +| :-- | :-------------------------------------------------------------------------------------------------------------- | :----- | +| 1 | SSH เข้า ASUSTOR ได้ (`ssh admin@192.168.10.9`) | ✅ | +| 2 | Docker Network `lcbp3` สร้างแล้ว (ดูหัวข้อ [สร้าง Docker Network](#-สร้าง-docker-network-ทำครั้งแรกครั้งเดียว)) | ✅ | +| 3 | สร้าง Directories และกำหนดสิทธิ์แล้ว (ดูหัวข้อ [กำหนดสิทธิ](#กำหนดสิทธิ-บน-asustor)) | ✅ | +| 4 | สร้าง `prometheus.yml` แล้ว (ดูหัวข้อ [Prometheus Configuration](#prometheus-configuration)) | ✅ | +| 5 | สร้าง `promtail-config.yml` แล้ว (ดูหัวข้อ [Step 1.2](#step-12-สร้าง-promtail-configyml)) | ✅ | --- @@ -628,7 +627,7 @@ cat /volume1/np-dms/monitoring/prometheus/config/prometheus.yml ต้องสร้าง Config ให้ Promtail อ่าน logs จาก Docker containers และส่งไป Loki: -```bash +````bash # สร้างไฟล์ promtail-config.yml cat > /volume1/np-dms/monitoring/promtail/config/promtail-config.yml << 'EOF' server: @@ -662,9 +661,10 @@ EOF CREATE USER 'exporter'@'%' IDENTIFIED BY 'Center2025' WITH MAX_USER_CONNECTIONS 3; GRANT PROCESS, REPLICATION CLIENT, SELECT, SLAVE MONITOR ON *.* TO 'exporter'@'%'; FLUSH PRIVILEGES; -``` +```` ### 2. สร้างไฟล์คอนฟิก .my.cnf บน QNAP + เพื่อให้ `mysqld-exporter` อ่านรหัสผ่านที่มีตัวอักษรพิเศษได้ถูกต้อง: 1. **SSH เข้า QNAP** (หรือใช้ File Station สร้าง Folder): @@ -678,11 +678,11 @@ FLUSH PRIVILEGES; 3. **สร้างไฟล์ .my.cnf**: ```bash cat > /share/np-dms/monitoring/mysqld-exporter/.my.cnf << 'EOF' -[client] -user=exporter -password=Center2025 -host=mariadb -EOF + [client] + user=exporter + password=Center2025 + host=mariadb + EOF ``` 4. **กำหนดสิทธิ์ไฟล์** (เพื่อให้ Container อ่านไฟล์ได้): ```bash @@ -690,8 +690,10 @@ EOF ``` # ตรวจสอบ + cat /volume1/np-dms/monitoring/promtail/config/promtail-config.yml -``` + +```` --- @@ -722,7 +724,7 @@ docker compose up -d # ตรวจสอบ container status docker compose ps -``` +```` --- @@ -735,15 +737,15 @@ docker ps --filter "name=prometheus" --filter "name=grafana" \ --filter "name=cadvisor" --filter "name=loki" --filter "name=promtail" ``` -| Service | วิธีตรวจสอบ | Expected Result | -| :---------------- | :----------------------------------------------------------------- | :------------------------------------ | -| ✅ **Prometheus** | `curl http://192.168.10.9:9090/-/healthy` | `Prometheus Server is Healthy` | -| ✅ **Grafana** | เปิด `https://grafana.np-dms.work` (หรือ `http://192.168.10.9:3000`) | หน้า Login | -| ✅ **Uptime Kuma** | เปิด `https://uptime.np-dms.work` (หรือ `http://192.168.10.9:3001`) | หน้า Setup | -| ✅ **Node Exp.** | `curl http://192.168.10.9:9100/metrics \| head` | Metrics output | -| ✅ **cAdvisor** | `curl http://192.168.10.9:8080/healthz` | `ok` | -| ✅ **Loki** | `curl http://192.168.10.9:3100/ready` | `ready` | -| ✅ **Promtail** | เช็ค Logs: `docker logs promtail` | ไม่ควรมี Error + เห็น connection success | +| Service | วิธีตรวจสอบ | Expected Result | +| :----------------- | :------------------------------------------------------------------- | :--------------------------------------- | +| ✅ **Prometheus** | `curl http://192.168.10.9:9090/-/healthy` | `Prometheus Server is Healthy` | +| ✅ **Grafana** | เปิด `https://grafana.np-dms.work` (หรือ `http://192.168.10.9:3000`) | หน้า Login | +| ✅ **Uptime Kuma** | เปิด `https://uptime.np-dms.work` (หรือ `http://192.168.10.9:3001`) | หน้า Setup | +| ✅ **Node Exp.** | `curl http://192.168.10.9:9100/metrics \| head` | Metrics output | +| ✅ **cAdvisor** | `curl http://192.168.10.9:8080/healthz` | `ok` | +| ✅ **Loki** | `curl http://192.168.10.9:3100/ready` | `ready` | +| ✅ **Promtail** | เช็ค Logs: `docker logs promtail` | ไม่ควรมี Error + เห็น connection success | --- @@ -797,30 +799,33 @@ curl -s http://localhost:9090/api/v1/targets | grep -E '"qnap-(node|cadvisor)"' เพื่อการ Monitor ที่สมบูรณ์ แนะนำให้ Import Dashboards ต่อไปนี้: #### 6.1 Host Monitoring (Node Exporter) -* **Concept:** ดู resource ของเครื่อง Host (CPU, RAM, Disk, Network) -* **Dashboard ID:** `1860` (Node Exporter Full) -* **วิธี Import:** - 1. ไปที่ **Dashboards** → **New** → **Import** - 2. ช่อง **Import via grafana.com** ใส่เลข `1860` กด **Load** - 3. เลือก Data source: **Prometheus** - 4. กด **Import** + +- **Concept:** ดู resource ของเครื่อง Host (CPU, RAM, Disk, Network) +- **Dashboard ID:** `1860` (Node Exporter Full) +- **วิธี Import:** + 1. ไปที่ **Dashboards** → **New** → **Import** + 2. ช่อง **Import via grafana.com** ใส่เลข `1860` กด **Load** + 3. เลือก Data source: **Prometheus** + 4. กด **Import** #### 6.2 Container Monitoring (cAdvisor) -* **Concept:** ดู resource ของแต่ละ Container (เชื่อม Logs ด้วย) -* **Dashboard ID:** `14282` (Cadvisor exporter) -* **วิธี Import:** - 1. ใส่เลข `14282` กด **Load** - 2. เลือก Data source: **Prometheus** - 3. กด **Import** + +- **Concept:** ดู resource ของแต่ละ Container (เชื่อม Logs ด้วย) +- **Dashboard ID:** `14282` (Cadvisor exporter) +- **วิธี Import:** + 1. ใส่เลข `14282` กด **Load** + 2. เลือก Data source: **Prometheus** + 3. กด **Import** #### 6.3 Logs Monitoring (Loki Integration) + เพื่อให้ Dashboard ของ Container แสดง Logs จาก Loki ได้ด้วย: 1. เปิด Dashboard **Cadvisor exporter** ที่เพิ่ง Import มา 2. กดปุ่ม **Add visualization** (หรือ Edit dashboard) 3. เลือก Data source: **Loki** 4. ในช่อง Query ใส่: `{container="$name"}` - * *(Note: `$name` มาจาก Variable ของ Dashboard 14282)* + - _(Note: `$name` มาจาก Variable ของ Dashboard 14282)_ 5. ปรับ Visualization type เป็น **Logs** 6. ตั้งชื่อ Panel ว่า **"Container Logs"** 7. กด **Apply** และ **Save Dashboard** @@ -850,4 +855,3 @@ curl -s http://localhost:9090/api/v1/targets | grep -E '"qnap-(node|cadvisor)"' --- > 📝 **หมายเหตุ**: เอกสารนี้อ้างอิงจาก Architecture Document **v1.8.0** - Monitoring Stack deploy บน ASUSTOR AS5403T - diff --git a/specs/99-archives/ADR-003-file-storage-approach.md b/specs/99-archives/ADR-003-file-storage-approach.md index a686e4a..725f8d2 100644 --- a/specs/99-archives/ADR-003-file-storage-approach.md +++ b/specs/99-archives/ADR-003-file-storage-approach.md @@ -280,16 +280,10 @@ export class FileStorageService { const now = new Date(); const year = now.getFullYear(); const month = (now.getMonth() + 1).toString().padStart(2, '0'); - const permanentDir = path.join( - this.PERMANENT_DIR, - year.toString(), - month - ); + const permanentDir = path.join(this.PERMANENT_DIR, year.toString(), month); await fs.ensureDir(permanentDir); - const permanentFilename = `${uuidv4()}_${ - tempAttachment.original_filename - }`; + const permanentFilename = `${uuidv4()}_${tempAttachment.original_filename}`; const permanentPath = path.join(permanentDir, permanentFilename); // 4. Move file @@ -348,10 +342,7 @@ export class FileStorageService { } private validateFile(file: Express.Multer.File): void { - const allowedTypes = [ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - ]; + const allowedTypes = ['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']; const maxSize = 50 * 1024 * 1024; // 50MB if (!allowedTypes.includes(file.mimetype)) { @@ -397,12 +388,7 @@ export class CorrespondenceController { // 2. Commit files (within transaction) if (dto.temp_file_ids?.length > 0) { - await this.fileStorage.commitFiles( - dto.temp_file_ids, - correspondence.id, - 'correspondence', - manager - ); + await this.fileStorage.commitFiles(dto.temp_file_ids, correspondence.id, 'correspondence', manager); } return correspondence; @@ -445,17 +431,14 @@ export class CorrespondenceController { ### File Validation 1. **Type Validation:** - - Check MIME type - Verify Magic Numbers (ไม่ใช่แค่ extension) 2. **Size Validation:** - - Max 50MB per file - Total max 500MB per form submission 3. **Virus Scanning:** - - ClamAV integration - Scan before saving to temp diff --git a/specs/99-archives/ADR-004-rbac-implementation.md b/specs/99-archives/ADR-004-rbac-implementation.md index 27c260b..7649350 100644 --- a/specs/99-archives/ADR-004-rbac-implementation.md +++ b/specs/99-archives/ADR-004-rbac-implementation.md @@ -229,10 +229,7 @@ export class PermissionGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { // Get required permission from decorator - const permission = this.reflector.get( - 'permission', - context.getHandler() - ); + const permission = this.reflector.get('permission', context.getHandler()); if (!permission) return true; diff --git a/specs/99-archives/ADR-007-api-design-error-handling.md b/specs/99-archives/ADR-007-api-design-error-handling.md index 9ad0ebe..42dc8fe 100644 --- a/specs/99-archives/ADR-007-api-design-error-handling.md +++ b/specs/99-archives/ADR-007-api-design-error-handling.md @@ -201,13 +201,7 @@ ```typescript // File: backend/src/common/filters/global-exception.filter.ts -import { - ExceptionFilter, - Catch, - ArgumentsHost, - HttpException, - HttpStatus, -} from '@nestjs/common'; +import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { @@ -268,10 +262,7 @@ export class BusinessException extends HttpException { } // Usage -throw new BusinessException( - 'Cannot approve correspondence in current status', - 'INVALID_WORKFLOW_TRANSITION' -); +throw new BusinessException('Cannot approve correspondence in current status', 'INVALID_WORKFLOW_TRANSITION'); ``` ### 6. Validation Pipe Configuration diff --git a/specs/99-archives/data-dictionary-v1.7.0.md b/specs/99-archives/data-dictionary-v1.7.0.md index bc599c1..2b7ea7f 100644 --- a/specs/99-archives/data-dictionary-v1.7.0.md +++ b/specs/99-archives/data-dictionary-v1.7.0.md @@ -3,6 +3,7 @@ เอกสารนี้สรุปโครงสร้างตาราง, FOREIGN KEYS (FK), และ Constraints ที่สำคัญทั้งหมดในฐานข้อมูล LCBP3 - DMS (v1.7.0) เพื่อใช้เป็นเอกสารอ้างอิงสำหรับทีมพัฒนา Backend (NestJS) และ Frontend (Next.js) โดยอิงจาก Requirements และ SQL Script ล่าสุด ** สถานะ: ** FINAL GUIDELINE ** วันที่: ** 2025 -12 -23 ** อ้างอิง: ** Requirements v1.7.0 & FullStackJS Guidelines v1.7.0 ** Classification: ** Internal Technical Documentation ## 📝 สรุปรายการปรับปรุง (Summary of Changes in v1.7.0) + 1. **Drawing Tables Restructuring**: - `contract_drawing_subcat_cat_maps`: เปลี่ยน PK จาก composite เป็น `id` AUTO_INCREMENT พร้อม UNIQUE constraint - `contract_drawings`: เปลี่ยน `sub_cat_id` → `map_cat_id` และเพิ่ม `volume_page` @@ -19,157 +20,147 @@ FOREIGN KEYS (FK), ### 1.1 organization_roles -* * Purpose **: MASTER TABLE FOR organization role TYPES IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ----------- | ----------- | --------------------------- | ---------------------------------------------------------------- | -| id | INT | PRIMARY KEY, -AUTO_INCREMENT | UNIQUE identifier FOR organization role | | role_name | VARCHAR(20) | NOT NULL, -UNIQUE | Role name ( - CONTRACTOR, - THIRD PARTY -) | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (role_name) ** Business Rules **: - Predefined system roles FOR organization TYPES - Cannot be deleted IF referenced by organizations --- +- - Purpose **: MASTER TABLE FOR organization role TYPES IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ----------- | ----------- | --------------------------- | ---------------------------------------------------------------- | + | id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR organization role | | role_name | VARCHAR(20) | NOT NULL, + UNIQUE | Role name ( + CONTRACTOR, + THIRD PARTY + ) | + | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | + | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (role_name) ** Business Rules \*\*: - Predefined system roles FOR organization TYPES - Cannot be deleted IF referenced by organizations --- ### 1.2 organizations -* * Purpose **: MASTER TABLE storing ALL organizations involved IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ----------------- | ------------ | ----------------------------------- | ---------------------------------------- | -| id | INT | PRIMARY KEY, -AUTO_INCREMENT | UNIQUE identifier FOR organization | | organization_code | VARCHAR(20) | NOT NULL, -UNIQUE | Organization code (e.g., 'กทท.', 'TEAM') | | organization_name | VARCHAR(255) | NOT NULL | FULL organization name | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS (1 = active, 0 = inactive) | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last -UPDATE timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (organization_code) - INDEX (is_active) ** Relationships **: - Referenced by: users, - project_organizations, - contract_organizations, - correspondences, - circulations --- +- - Purpose **: MASTER TABLE storing ALL organizations involved IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ----------------- | ------------ | ----------------------------------- | ---------------------------------------- | + | id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR organization | | organization_code | VARCHAR(20) | NOT NULL, + UNIQUE | Organization code (e.g., 'กทท.', 'TEAM') | | organization_name | VARCHAR(255) | NOT NULL | FULL organization name | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS (1 = active, 0 = inactive) | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last + UPDATE timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (organization_code) - INDEX (is_active) ** Relationships \*\*: - Referenced by: users, + project_organizations, + contract_organizations, + correspondences, + circulations --- - ### 1.3 projects + ### 1.3 projects + - - Purpose **: MASTER TABLE FOR ALL projects IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ------------ | ------------ | --------------------------- | ----------------------------- | + | id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR project | | project_code | VARCHAR(50) | NOT NULL, + UNIQUE | Project code (e.g., 'LCBP3') | | project_name | VARCHAR(255) | NOT NULL | FULL project name | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | + | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | + | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | + ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (project_code) - INDEX (is_active) ** Relationships \*\*: - Referenced by: contracts, + correspondences, + document_number_formats, + drawings --- - * * Purpose **: MASTER TABLE FOR ALL projects IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ------------ | ------------ | --------------------------- | ----------------------------- | - | id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier FOR project | | project_code | VARCHAR(50) | NOT NULL, - UNIQUE | Project code (e.g., 'LCBP3') | | project_name | VARCHAR(255) | NOT NULL | FULL project name | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | -** INDEXES **: - PRIMARY KEY (id) - UNIQUE (project_code) - INDEX (is_active) ** Relationships **: - Referenced by: contracts, - correspondences, - document_number_formats, - drawings --- + ### 1.4 contracts + - - Purpose **: MASTER TABLE FOR contracts within projects | COLUMN Name | Data TYPE | Constraints | Description | | ------------- | ------------ | ----------------------------------- | ------------------------------ | + | id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR contract | | project_id | INT | NOT NULL, + FK | Reference TO projects TABLE | | contract_code | VARCHAR(50) | NOT NULL, + UNIQUE | Contract code | | contract_name | VARCHAR(255) | NOT NULL | FULL contract name | | description | TEXT | NULL | Contract description | | start_date | DATE | NULL | Contract START date | | end_date | DATE | NULL | Contract + END date | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last + UPDATE timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (contract_code) - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - INDEX (project_id, is_active) ** Relationships \*\*: - Parent: projects - Referenced by: contract_organizations, + user_assignments --- - ### 1.4 contracts + ### 1.5 disciplines (NEW v1.5.1) + - - Purpose **: เก็บข้อมูลสาขางาน (Disciplines) แยกตามสัญญา (Req 6B) | COLUMN Name | Data TYPE | Constraints | Description | |: -------------- | :----------- | :----------- | :--------------------- | + | id | INT | PK, + AI | UNIQUE identifier | | contract_id | INT | FK, + NOT NULL | ผูกกับสัญญา | | discipline_code | VARCHAR(10) | NOT NULL | รหัสสาขา (เช่น GEN, STR) | | code_name_th | VARCHAR(255) | NULL | ชื่อไทย | | code_name_en | VARCHAR(255) | NULL | ชื่ออังกฤษ | | is_active | TINYINT(1) | DEFAULT 1 | สถานะการใช้งาน | ** INDEXES \*\*: - UNIQUE (contract_id, discipline_code) --- - * * Purpose **: MASTER TABLE FOR contracts within projects | COLUMN Name | Data TYPE | Constraints | Description | | ------------- | ------------ | ----------------------------------- | ------------------------------ | - | id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier FOR contract | | project_id | INT | NOT NULL, - FK | Reference TO projects TABLE | | contract_code | VARCHAR(50) | NOT NULL, - UNIQUE | Contract code | | contract_name | VARCHAR(255) | NOT NULL | FULL contract name | | description | TEXT | NULL | Contract description | | start_date | DATE | NULL | Contract START date | | end_date | DATE | NULL | Contract -END date | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last -UPDATE timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (contract_code) - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - INDEX (project_id, is_active) ** Relationships **: - Parent: projects - Referenced by: contract_organizations, - user_assignments --- + ## **2. 👥 Users & RBAC Tables (ผู้ใช้, สิทธิ์, บทบาท)** - ### 1.5 disciplines (NEW v1.5.1) + ### 2.1 users + - - Purpose **: MASTER TABLE storing ALL system users | COLUMN Name | Data TYPE | Constraints | Description | | ----------------------- | ------------ | ----------------------------------- | -------------------------------- | + | user_id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR user | | username | VARCHAR(50) | NOT NULL, + UNIQUE | Login username | | password_hash | VARCHAR(255) | NOT NULL | Hashed PASSWORD (bcrypt) | | first_name | VARCHAR(50) | NULL | User 's first name | + | last_name | VARCHAR(50) | NULL | User' s last name | | email | VARCHAR(100) | NOT NULL, + UNIQUE | Email address | | line_id | VARCHAR(100) | NULL | LINE messenger ID | | primary_organization_id | INT | NULL, + FK | PRIMARY organization affiliation | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | | failed_attempts | INT | DEFAULT 0 | Failed login attempts counter | | locked_until | DATETIME | NULL | Account LOCK expiration time | | last_login_at | TIMESTAMP | NULL | Last successful login timestamp | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last + UPDATE timestamp | | deleted_at | DATETIME | NULL | Deleted at | ** INDEXES **: - PRIMARY KEY (user_id) - UNIQUE (username) - UNIQUE (email) - FOREIGN KEY (primary_organization_id) REFERENCES organizations(id) ON DELETE + SET NULL - INDEX (is_active) - INDEX (email) ** Relationships \*\*: - Parent: organizations (primary_organization_id) - Referenced by: user_assignments, + audit_logs, + notifications, + circulation_routings --- - * * Purpose **: เก็บข้อมูลสาขางาน (Disciplines) แยกตามสัญญา (Req 6B) | COLUMN Name | Data TYPE | Constraints | Description | |: -------------- | :----------- | :----------- | :--------------------- | - | id | INT | PK, - AI | UNIQUE identifier | | contract_id | INT | FK, - NOT NULL | ผูกกับสัญญา | | discipline_code | VARCHAR(10) | NOT NULL | รหัสสาขา (เช่น GEN, STR) | | code_name_th | VARCHAR(255) | NULL | ชื่อไทย | | code_name_en | VARCHAR(255) | NULL | ชื่ออังกฤษ | | is_active | TINYINT(1) | DEFAULT 1 | สถานะการใช้งาน | ** INDEXES **: - UNIQUE (contract_id, discipline_code) --- + ### 2.2 roles + - - Purpose **: MASTER TABLE defining system roles WITH scope levels | COLUMN Name | Data TYPE | Constraints | Description | | ----------- | ------------ | --------------------------- | ---------------------------------------------------- | + | role_id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR role | | role_name | VARCHAR(100) | NOT NULL | Role name (e.g., 'Superadmin', 'Document Control') | | scope | ENUM | NOT NULL | Scope LEVEL: GLOBAL, + Organization, + Project, + Contract | | description | TEXT | NULL | Role description | | is_system | BOOLEAN | DEFAULT FALSE | System role flag (cannot be deleted) | + | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | + | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | + ** INDEXES **: - PRIMARY KEY (role_id) - INDEX (scope) ** Relationships \*\*: - Referenced by: role_permissions, + user_assignments --- - ## **2. 👥 Users & RBAC Tables (ผู้ใช้, สิทธิ์, บทบาท)** + ### 2.3 permissions + - - Purpose **: MASTER TABLE defining ALL system permissions | COLUMN Name | Data TYPE | Constraints | Description | | --------------- | ------------ | --------------------------- | ------------------------------------------------------ | + | permission_id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier FOR permission | | permission_name | VARCHAR(100) | NOT NULL, + UNIQUE | Permission code (e.g., 'rfas.create', 'document.view') | | description | TEXT | NULL | Permission description | | module | VARCHAR(50) | NULL | Related module name | | scope_level | ENUM | NULL | Scope: GLOBAL, + ORG, + PROJECT | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | + | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | + | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | + | deleted_at | DATETIME | NULL | Soft delete timestamp | + ** INDEXES **: - PRIMARY KEY (permission_id) - UNIQUE (permission_name) - INDEX (module) - INDEX (scope_level) - INDEX (is_active) ** Relationships \*\*: - Referenced by: role_permissions --- - ### 2.1 users + ### 2.4 role_permissions + - - Purpose **: Junction TABLE mapping roles TO permissions (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | ------------- | --------- | --------------- | ------------------------------ | + | role_id | INT | PRIMARY KEY, + FK | Reference TO roles TABLE | | permission_id | INT | PRIMARY KEY, + FK | Reference TO permissions TABLE | ** INDEXES **: - PRIMARY KEY (role_id, permission_id) - FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE - FOREIGN KEY (permission_id) REFERENCES permissions(permission_id) ON DELETE CASCADE - INDEX (permission_id) ** Relationships \*\*: - Parent: roles, + permissions --- - * * Purpose **: MASTER TABLE storing ALL system users | COLUMN Name | Data TYPE | Constraints | Description | | ----------------------- | ------------ | ----------------------------------- | -------------------------------- | - | user_id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier FOR user | | username | VARCHAR(50) | NOT NULL, - UNIQUE | Login username | | password_hash | VARCHAR(255) | NOT NULL | Hashed PASSWORD (bcrypt) | | first_name | VARCHAR(50) | NULL | User 's first name | -| last_name | VARCHAR(50) | NULL | User' s last name | | email | VARCHAR(100) | NOT NULL, - UNIQUE | Email address | | line_id | VARCHAR(100) | NULL | LINE messenger ID | | primary_organization_id | INT | NULL, - FK | PRIMARY organization affiliation | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | | failed_attempts | INT | DEFAULT 0 | Failed login attempts counter | | locked_until | DATETIME | NULL | Account LOCK expiration time | | last_login_at | TIMESTAMP | NULL | Last successful login timestamp | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last -UPDATE timestamp | | deleted_at | DATETIME | NULL | Deleted at | ** INDEXES **: - PRIMARY KEY (user_id) - UNIQUE (username) - UNIQUE (email) - FOREIGN KEY (primary_organization_id) REFERENCES organizations(id) ON DELETE -SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: organizations (primary_organization_id) - Referenced by: user_assignments, - audit_logs, - notifications, - circulation_routings --- + ### 2.5 user_assignments + - - Purpose **: Junction TABLE assigning users TO roles WITH scope context | COLUMN Name | Data TYPE | Constraints | Description | | ------------------- | --------- | --------------------------- | ---------------------------------- | + | id | INT | PRIMARY KEY, + AUTO_INCREMENT | UNIQUE identifier | | user_id | INT | NOT NULL, + FK | Reference TO users TABLE | | role_id | INT | NOT NULL, + FK | Reference TO roles TABLE | | organization_id | INT | NULL, + FK | Organization scope (IF applicable) | | project_id | INT | NULL, + FK | Project scope (IF applicable) | | contract_id | INT | NULL, + FK | Contract scope (IF applicable) | | assigned_by_user_id | INT | NULL, + FK | User who made the assignment | | assigned_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Assignment timestamp | ** INDEXES **: - PRIMARY KEY (id) - FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE - FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE - FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE - FOREIGN KEY (assigned_by_user_id) REFERENCES users(user_id) - INDEX (user_id, role_id) - INDEX (organization_id) - INDEX (project_id) - INDEX (contract_id) ** Relationships \*\*: - Parent: users, + roles, + organizations, + projects, + contracts --- - ### 2.2 roles + ### 2.6 project_organizations + - - Purpose **: Junction TABLE linking projects TO participating organizations (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | --------------- | --------- | --------------- | -------------------------------- | + | project_id | INT | PRIMARY KEY, + FK | Reference TO projects TABLE | | organization_id | INT | PRIMARY KEY, + FK | Reference TO organizations TABLE | ** INDEXES **: - PRIMARY KEY (project_id, organization_id) - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE - INDEX (organization_id) ** Relationships \*\*: - Parent: projects, + organizations --- - * * Purpose **: MASTER TABLE defining system roles WITH scope levels | COLUMN Name | Data TYPE | Constraints | Description | | ----------- | ------------ | --------------------------- | ---------------------------------------------------- | - | role_id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier FOR role | | role_name | VARCHAR(100) | NOT NULL | Role name (e.g., 'Superadmin', 'Document Control') | | scope | ENUM | NOT NULL | Scope LEVEL: GLOBAL, - Organization, - Project, - Contract | | description | TEXT | NULL | Role description | | is_system | BOOLEAN | DEFAULT FALSE | System role flag (cannot be deleted) | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | -** INDEXES **: - PRIMARY KEY (role_id) - INDEX (scope) ** Relationships **: - Referenced by: role_permissions, - user_assignments --- - - ### 2.3 permissions - - * * Purpose **: MASTER TABLE defining ALL system permissions | COLUMN Name | Data TYPE | Constraints | Description | | --------------- | ------------ | --------------------------- | ------------------------------------------------------ | - | permission_id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier FOR permission | | permission_name | VARCHAR(100) | NOT NULL, - UNIQUE | Permission code (e.g., 'rfas.create', 'document.view') | | description | TEXT | NULL | Permission description | | module | VARCHAR(50) | NULL | Related module name | | scope_level | ENUM | NULL | Scope: GLOBAL, - ORG, - PROJECT | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| deleted_at | DATETIME | NULL | Soft delete timestamp | -** INDEXES **: - PRIMARY KEY (permission_id) - UNIQUE (permission_name) - INDEX (module) - INDEX (scope_level) - INDEX (is_active) ** Relationships **: - Referenced by: role_permissions --- - - ### 2.4 role_permissions - - * * Purpose **: Junction TABLE mapping roles TO permissions (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | ------------- | --------- | --------------- | ------------------------------ | - | role_id | INT | PRIMARY KEY, - FK | Reference TO roles TABLE | | permission_id | INT | PRIMARY KEY, - FK | Reference TO permissions TABLE | ** INDEXES **: - PRIMARY KEY (role_id, permission_id) - FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE - FOREIGN KEY (permission_id) REFERENCES permissions(permission_id) ON DELETE CASCADE - INDEX (permission_id) ** Relationships **: - Parent: roles, - permissions --- - - ### 2.5 user_assignments - - * * Purpose **: Junction TABLE assigning users TO roles WITH scope context | COLUMN Name | Data TYPE | Constraints | Description | | ------------------- | --------- | --------------------------- | ---------------------------------- | - | id | INT | PRIMARY KEY, - AUTO_INCREMENT | UNIQUE identifier | | user_id | INT | NOT NULL, - FK | Reference TO users TABLE | | role_id | INT | NOT NULL, - FK | Reference TO roles TABLE | | organization_id | INT | NULL, - FK | Organization scope (IF applicable) | | project_id | INT | NULL, - FK | Project scope (IF applicable) | | contract_id | INT | NULL, - FK | Contract scope (IF applicable) | | assigned_by_user_id | INT | NULL, - FK | User who made the assignment | | assigned_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Assignment timestamp | ** INDEXES **: - PRIMARY KEY (id) - FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE - FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE - FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE - FOREIGN KEY (assigned_by_user_id) REFERENCES users(user_id) - INDEX (user_id, role_id) - INDEX (organization_id) - INDEX (project_id) - INDEX (contract_id) ** Relationships **: - Parent: users, - roles, - organizations, - projects, - contracts --- - - ### 2.6 project_organizations - - * * Purpose **: Junction TABLE linking projects TO participating organizations (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | --------------- | --------- | --------------- | -------------------------------- | - | project_id | INT | PRIMARY KEY, - FK | Reference TO projects TABLE | | organization_id | INT | PRIMARY KEY, - FK | Reference TO organizations TABLE | ** INDEXES **: - PRIMARY KEY (project_id, organization_id) - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE - INDEX (organization_id) ** Relationships **: - Parent: projects, - organizations --- - - ### 2.7 contract_organizations - - * * Purpose **: Junction TABLE linking contracts TO participating organizations WITH roles (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | ---------------- | ------------ | --------------- | ------------------------------------------------------------------------- | - | contract_id | INT | PRIMARY KEY, - FK | Reference TO contracts TABLE | | organization_id | INT | PRIMARY KEY, - FK | Reference TO organizations TABLE | | role_in_contract | VARCHAR(100) | NULL | Organization 's role in contract (Owner, Designer, Consultant, Contractor) | + ### 2.7 contract_organizations + - - Purpose \*\*: Junction TABLE linking contracts TO participating organizations WITH roles (M :N) | COLUMN Name | Data TYPE | Constraints | Description | | ---------------- | ------------ | --------------- | ------------------------------------------------------------------------- | + | contract_id | INT | PRIMARY KEY, + FK | Reference TO contracts TABLE | | organization_id | INT | PRIMARY KEY, + FK | Reference TO organizations TABLE | | role_in_contract | VARCHAR(100) | NULL | Organization 's role in contract (Owner, Designer, Consultant, Contractor) | **Indexes**: -* PRIMARY KEY (contract_id, organization_id) -* FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE -* FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE -* INDEX (organization_id) -* INDEX (role_in_contract) +- PRIMARY KEY (contract_id, organization_id) +- FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE +- FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE +- INDEX (organization_id) +- INDEX (role_in_contract) **Relationships**: -* Parent: contracts, organizations +- Parent: contracts, organizations --- @@ -177,13 +168,13 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Purpose**: เก็บการตั้งค่าส่วนตัวของผู้ใช้ (Req 5.5, 6.8.3) -| Column Name | Data Type | Constraints | Description | -| :----------- | :---------- | :---------------- | :-------------- | -| user_id | INT | PK, FK | User ID | -| notify_email | BOOLEAN | DEFAULT TRUE | รับอีเมลแจ้งเตือน | -| notify_line | BOOLEAN | DEFAULT TRUE | รับไลน์แจ้งเตือน | +| Column Name | Data Type | Constraints | Description | +| :----------- | :---------- | :---------------- | :----------------- | +| user_id | INT | PK, FK | User ID | +| notify_email | BOOLEAN | DEFAULT TRUE | รับอีเมลแจ้งเตือน | +| notify_line | BOOLEAN | DEFAULT TRUE | รับไลน์แจ้งเตือน | | digest_mode | BOOLEAN | DEFAULT FALSE | รับแจ้งเตือนแบบรวม | -| ui_theme | VARCHAR(20) | DEFAULT ' light ' | UI Theme | +| ui_theme | VARCHAR(20) | DEFAULT ' light ' | UI Theme | --- @@ -191,25 +182,25 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Purpose**: เก็บ Refresh Tokens สำหรับการทำ Authentication และ Token Rotation -| Column Name | Data Type | Constraints | Description | -| :---------------- | :----------- | :------------------------ | :------------------------------------ | -| token_id | INT | PK, AI | Unique Token ID | -| user_id | INT | FK, NOT NULL | เจ้าของ Token | -| token_hash | VARCHAR(255) | NOT NULL | Hash ของ Refresh Token (Security) | -| expires_at | DATETIME | NOT NULL | วันหมดอายุของ Token | -| is_revoked | BOOLEAN | DEFAULT FALSE | สถานะถูกยกเลิก (True = ใช้งานไม่ได้) | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | เวลาที่สร้าง | +| Column Name | Data Type | Constraints | Description | +| :---------------- | :----------- | :------------------------ | :------------------------------------------ | +| token_id | INT | PK, AI | Unique Token ID | +| user_id | INT | FK, NOT NULL | เจ้าของ Token | +| token_hash | VARCHAR(255) | NOT NULL | Hash ของ Refresh Token (Security) | +| expires_at | DATETIME | NOT NULL | วันหมดอายุของ Token | +| is_revoked | BOOLEAN | DEFAULT FALSE | สถานะถูกยกเลิก (True = ใช้งานไม่ได้) | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | เวลาที่สร้าง | | replaced_by_token | VARCHAR(255) | NULL | Token ใหม่ที่มาแทนที่ (กรณี Token Rotation) | **Indexes**: -* PRIMARY KEY (token_id) -* FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE -* INDEX (user_id) +- PRIMARY KEY (token_id) +- FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE +- INDEX (user_id) **Relationships**: -* Parent: users +- Parent: users --- @@ -232,14 +223,14 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* UNIQUE (type_code) -* INDEX (is_active) -* INDEX (sort_order) +- PRIMARY KEY (id) +- UNIQUE (type_code) +- INDEX (is_active) +- INDEX (sort_order) **Relationships**: -* Referenced by: correspondences, document_number_formats, document_number_counters +- Referenced by: correspondences, document_number_formats, document_number_counters --- @@ -247,13 +238,13 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Purpose**: เก็บประเภทหนังสือย่อย (Sub Types) สำหรับ Mapping เลขรหัส (Req 6B) -| Column Name | Data Type | Constraints | Description | -| :--------------------- | :----------- | :----------- | :------------------------ | -| id | INT | PK, AI | Unique identifier | +| Column Name | Data Type | Constraints | Description | +| :--------------------- | :----------- | :----------- | :--------------------------- | +| id | INT | PK, AI | Unique identifier | | contract_id | INT | FK, NOT NULL | ผูกกับสัญญา | | correspondence_type_id | INT | FK, NOT NULL | ผูกกับประเภทเอกสารหลัก | | sub_type_code | VARCHAR(20) | NOT NULL | รหัสย่อย (เช่น MAT, SHP) | -| sub_type_name | VARCHAR(255) | NULL | ชื่อประเภทหนังสือย่อย | +| sub_type_name | VARCHAR(255) | NULL | ชื่อประเภทหนังสือย่อย | | sub_type_number | VARCHAR(10) | NULL | เลขรหัสสำหรับ Running Number | --- @@ -267,7 +258,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga | id | INT | PRIMARY KEY, AUTO_INCREMENT | Master correspondence ID | | correspondence_number | VARCHAR(100) | NOT NULL | Document number (from numbering system) | | correspondence_type_id | INT | NOT NULL, FK | Reference to correspondence_types | -| **discipline_id** | **INT** | **NULL, FK** | **[NEW] สาขางาน (ถ้ามี)** | +| **discipline_id** | **INT** | **NULL, FK** | **[NEW] สาขางาน (ถ้ามี)** | | is_internal_communication | TINYINT(1) | DEFAULT 0 | Internal (1) or external (0) communication | | project_id | INT | NOT NULL, FK | Reference to projects table | | originator_id | INT | NULL, FK | Originating organization | @@ -277,21 +268,21 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE RESTRICT -* **FOREIGN KEY (discipline_id) REFERENCES disciplines(id) ON DELETE SET NULL** -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* FOREIGN KEY (originator_id) REFERENCES organizations(id) ON DELETE SET NULL -* FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL -* UNIQUE KEY (project_id, correspondence_number) -* INDEX (correspondence_type_id) -* INDEX (originator_id) -* INDEX (deleted_at) +- PRIMARY KEY (id) +- FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE RESTRICT +- **FOREIGN KEY (discipline_id) REFERENCES disciplines(id) ON DELETE SET NULL** +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- FOREIGN KEY (originator_id) REFERENCES organizations(id) ON DELETE SET NULL +- FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL +- UNIQUE KEY (project_id, correspondence_number) +- INDEX (correspondence_type_id) +- INDEX (originator_id) +- INDEX (deleted_at) **Relationships**: -* Parent: correspondence_types, **disciplines**, projects, organizations, users -* Children: correspondence_revisions, correspondence_recipients, correspondence_tags, correspondence_references, correspondence_attachments, circulations, transmittals +- Parent: correspondence_types, **disciplines**, projects, organizations, users +- Children: correspondence_revisions, correspondence_recipients, correspondence_tags, correspondence_references, correspondence_attachments, circulations, transmittals --- @@ -299,44 +290,44 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Purpose**: Child table storing revision history of correspondences (1:N) -| Column Name | Data Type | Constraints | Description | -| ------------------------ | ------------ | --------------------------------- | -------------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | -| correspondence_id | INT | NOT NULL, FK | Master correspondence ID | -| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | -| revision_label | VARCHAR(10) | NULL | Display revision (A, B, 1.1...) | -| is_current | BOOLEAN | DEFAULT FALSE | Current revision flag | -| correspondence_status_id | INT | NOT NULL, FK | Current status of this revision | -| title | VARCHAR(255) | NOT NULL | Document title | -| document_date | DATE | NULL | Document date | -| issued_date | DATETIME | NULL | Issue date | -| received_date | DATETIME | NULL | Received date | -| due_date | DATETIME | NULL | Due date for response | -| description | TEXT | NULL | Revision description | -| details | JSON | NULL | Type-specific details (e.g., RFI questions) | -| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | -| created_by | INT | NULL, FK | User who created revision | -| updated_by | INT | NULL, FK | User who last updated | +| Column Name | Data Type | Constraints | Description | +| ------------------------ | ------------ | --------------------------------- | ------------------------------------------------------------ | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | +| correspondence_id | INT | NOT NULL, FK | Master correspondence ID | +| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | +| revision_label | VARCHAR(10) | NULL | Display revision (A, B, 1.1...) | +| is_current | BOOLEAN | DEFAULT FALSE | Current revision flag | +| correspondence_status_id | INT | NOT NULL, FK | Current status of this revision | +| title | VARCHAR(255) | NOT NULL | Document title | +| document_date | DATE | NULL | Document date | +| issued_date | DATETIME | NULL | Issue date | +| received_date | DATETIME | NULL | Received date | +| due_date | DATETIME | NULL | Due date for response | +| description | TEXT | NULL | Revision description | +| details | JSON | NULL | Type-specific details (e.g., RFI questions) | +| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | +| created_by | INT | NULL, FK | User who created revision | +| updated_by | INT | NULL, FK | User who last updated | | v_ref_project_id | INT | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Project ID จาก JSON details เพื่อทำ Index | -| v_doc_subtype | VARCHAR(50) | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Type จาก JSON details | -| schema_version | INT | DEFAULT 1 | Version of the schema used with this details | +| v_doc_subtype | VARCHAR(50) | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Type จาก JSON details | +| schema_version | INT | DEFAULT 1 | Version of the schema used with this details | **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (correspondence_status_id) REFERENCES correspondence_status(id) ON DELETE RESTRICT -* FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL -* FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL -* UNIQUE KEY (correspondence_id, revision_number) -* UNIQUE KEY (correspondence_id, is_current) -* INDEX (correspondence_status_id) -* INDEX (is_current) -* INDEX (document_date) -* INDEX (issued_date) -* INDEX (v_ref_project_id) -* INDEX (v_doc_subtype) +- PRIMARY KEY (id) +- FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (correspondence_status_id) REFERENCES correspondence_status(id) ON DELETE RESTRICT +- FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL +- FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL +- UNIQUE KEY (correspondence_id, revision_number) +- UNIQUE KEY (correspondence_id, is_current) +- INDEX (correspondence_status_id) +- INDEX (is_current) +- INDEX (document_date) +- INDEX (issued_date) +- INDEX (v_ref_project_id) +- INDEX (v_doc_subtype) --- @@ -352,15 +343,15 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (correspondence_id, recipient_organization_id, recipient_type) -* FOREIGN KEY (correspondence_id) REFERENCES correspondence_revisions(correspondence_id) ON DELETE CASCADE -* FOREIGN KEY (recipient_organization_id) REFERENCES organizations(id) ON DELETE RESTRICT -* INDEX (recipient_organization_id) -* INDEX (recipient_type) +- PRIMARY KEY (correspondence_id, recipient_organization_id, recipient_type) +- FOREIGN KEY (correspondence_id) REFERENCES correspondence_revisions(correspondence_id) ON DELETE CASCADE +- FOREIGN KEY (recipient_organization_id) REFERENCES organizations(id) ON DELETE RESTRICT +- INDEX (recipient_organization_id) +- INDEX (recipient_type) **Relationships**: -* Parent: correspondences, organizations +- Parent: correspondences, organizations --- @@ -378,13 +369,13 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* UNIQUE (tag_name) -* INDEX (tag_name) - For autocomplete +- PRIMARY KEY (id) +- UNIQUE (tag_name) +- INDEX (tag_name) - For autocomplete **Relationships**: -* Referenced by: correspondence_tags +- Referenced by: correspondence_tags --- @@ -399,14 +390,14 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (correspondence_id, tag_id) -* FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE -* INDEX (tag_id) +- PRIMARY KEY (correspondence_id, tag_id) +- FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +- INDEX (tag_id) **Relationships**: -* Parent: correspondences, tags +- Parent: correspondences, tags --- @@ -421,14 +412,14 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (src_correspondence_id, tgt_correspondence_id) -* FOREIGN KEY (src_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (tgt_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* INDEX (tgt_correspondence_id) +- PRIMARY KEY (src_correspondence_id, tgt_correspondence_id) +- FOREIGN KEY (src_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (tgt_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- INDEX (tgt_correspondence_id) **Relationships**: -* Parent: correspondences (both sides) +- Parent: correspondences (both sides) --- @@ -450,14 +441,14 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* UNIQUE (contract_id, type_code) -* FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE -* INDEX (is_active) +- PRIMARY KEY (id) +- UNIQUE (contract_id, type_code) +- FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE +- INDEX (is_active) **Relationships**: -* Referenced by: rfas +- Referenced by: rfas --- @@ -476,14 +467,14 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* UNIQUE (status_code) -* INDEX (is_active) -* INDEX (sort_order) +- PRIMARY KEY (id) +- UNIQUE (status_code) +- INDEX (is_active) +- INDEX (sort_order) **Relationships**: -* Referenced by: rfa_revisions +- Referenced by: rfa_revisions --- @@ -502,14 +493,14 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* UNIQUE (approve_code) -* INDEX (is_active) -* INDEX (sort_order) +- PRIMARY KEY (id) +- UNIQUE (approve_code) +- INDEX (is_active) +- INDEX (sort_order) **Relationships**: -* Referenced by: rfa_revisions +- Referenced by: rfa_revisions --- @@ -527,17 +518,17 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (rfa_type_id) REFERENCES rfa_types(id) -* FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL -* INDEX (rfa_type_id) -* INDEX (deleted_at) +- PRIMARY KEY (id) +- FOREIGN KEY (id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (rfa_type_id) REFERENCES rfa_types(id) +- FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL +- INDEX (rfa_type_id) +- INDEX (deleted_at) **Relationships**: -* Parent: correspondences, rfa_types, users -* Children: rfa_revisions +- Parent: correspondences, rfa_types, users +- Children: rfa_revisions --- @@ -549,44 +540,44 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga | ----------- | --------- | --------------------------- | ------------------ | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | -| rfa_id | INT | NOT NULL, FK | Master RFA ID | -| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | -| revision_label | VARCHAR(10) | NULL | Display revision (A, B, 1.1...) | -| is_current | BOOLEAN | DEFAULT FALSE | Current revision flag | -| rfa_status_code_id | INT | NOT NULL, FK | Current RFA status | -| rfa_approve_code_id | INT | NULL, FK | Approval result code | -| title | VARCHAR(255) | NOT NULL | RFA title | -| document_date | DATE | NULL | Document date | -| issued_date | DATE | NULL | Issue date for approval | -| received_date | DATETIME | NULL | Received date | -| approved_date | DATE | NULL | Approval date | -| description | TEXT | NULL | Revision description | -| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | -| created_by | INT | NULL, FK | User who created revision | -| updated_by | INT | NULL, FK | User who last updated | -| details | JSON | NULL | Type-specific details (e.g., RFI questions) | -| v_ref_drawing_count | INT | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Drawing Count จาก JSON details เพื่อทำ Index | -| schema_version | INT | DEFAULT 1 | Version of the schema used with this details | +| rfa_id | INT | NOT NULL, FK | Master RFA ID | +| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | +| revision_label | VARCHAR(10) | NULL | Display revision (A, B, 1.1...) | +| is_current | BOOLEAN | DEFAULT FALSE | Current revision flag | +| rfa_status_code_id | INT | NOT NULL, FK | Current RFA status | +| rfa_approve_code_id | INT | NULL, FK | Approval result code | +| title | VARCHAR(255) | NOT NULL | RFA title | +| document_date | DATE | NULL | Document date | +| issued_date | DATE | NULL | Issue date for approval | +| received_date | DATETIME | NULL | Received date | +| approved_date | DATE | NULL | Approval date | +| description | TEXT | NULL | Revision description | +| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | +| created_by | INT | NULL, FK | User who created revision | +| updated_by | INT | NULL, FK | User who last updated | +| details | JSON | NULL | Type-specific details (e.g., RFI questions) | +| v_ref_drawing_count | INT | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Drawing Count จาก JSON details เพื่อทำ Index | +| schema_version | INT | DEFAULT 1 | Version of the schema used with this details | **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (rfa_id) REFERENCES rfas(id) ON DELETE CASCADE -* FOREIGN KEY (rfa_status_code_id) REFERENCES rfa_status_codes(id) -* FOREIGN KEY (rfa_approve_code_id) REFERENCES rfa_approve_codes(id) ON DELETE SET NULL -* FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL -* FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL -* UNIQUE KEY (rfa_id, revision_number) -* UNIQUE KEY (rfa_id, is_current) -* INDEX (rfa_status_code_id) -* INDEX (rfa_approve_code_id) -* INDEX (is_current) -* INDEX (v_ref_drawing_count): ตัวอย่างการ Index ข้อมูลตัวเลขใน JSON +- PRIMARY KEY (id) +- FOREIGN KEY (rfa_id) REFERENCES rfas(id) ON DELETE CASCADE +- FOREIGN KEY (rfa_status_code_id) REFERENCES rfa_status_codes(id) +- FOREIGN KEY (rfa_approve_code_id) REFERENCES rfa_approve_codes(id) ON DELETE SET NULL +- FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL +- FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL +- UNIQUE KEY (rfa_id, revision_number) +- UNIQUE KEY (rfa_id, is_current) +- INDEX (rfa_status_code_id) +- INDEX (rfa_approve_code_id) +- INDEX (is_current) +- INDEX (v_ref_drawing_count): ตัวอย่างการ Index ข้อมูลตัวเลขใน JSON **Relationships**: -* Parent: correspondences, rfas, rfa_status_codes, rfa_approve_codes, users -* Children: rfa_items +- Parent: correspondences, rfas, rfa_status_codes, rfa_approve_codes, users +- Children: rfa_items --- @@ -601,24 +592,23 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (rfa_revision_id, shop_drawing_revision_id) -* FOREIGN KEY (rfa_revision_id) REFERENCES rfa_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE -* INDEX (shop_drawing_revision_id) +- PRIMARY KEY (rfa_revision_id, shop_drawing_revision_id) +- FOREIGN KEY (rfa_revision_id) REFERENCES rfa_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE +- INDEX (shop_drawing_revision_id) **Relationships**: -* Parent: rfa_revisions, shop_drawing_revisions +- Parent: rfa_revisions, shop_drawing_revisions **Business Rules**: -* Used primarily for RFA type = ' DWG ' (Shop Drawing) -* One RFA can contain multiple shop drawings -* One shop drawing can be referenced by multiple RFAs +- Used primarily for RFA type = ' DWG ' (Shop Drawing) +- One RFA can contain multiple shop drawings +- One shop drawing can be referenced by multiple RFAs --- - --- ## **5. 📐 Drawings Tables (แบบ, หมวดหมู่)** @@ -640,20 +630,20 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* UNIQUE KEY (project_id, volume_code) -* INDEX (sort_order) +- PRIMARY KEY (id) +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- UNIQUE KEY (project_id, volume_code) +- INDEX (sort_order) **Relationships**: -* Parent: projects -* Referenced by: contract_drawings +- Parent: projects +- Referenced by: contract_drawings **Business Rules**: -* Volume codes must be unique within a project -* Used for organizing large sets of contract drawings +- Volume codes must be unique within a project +- Used for organizing large sets of contract drawings --- @@ -674,20 +664,20 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* UNIQUE KEY (project_id, cat_code) -* INDEX (sort_order) +- PRIMARY KEY (id) +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- UNIQUE KEY (project_id, cat_code) +- INDEX (sort_order) **Relationships**: -* Parent: projects -* Referenced by: contract_drawing_subcat_cat_maps +- Parent: projects +- Referenced by: contract_drawing_subcat_cat_maps **Business Rules**: -* Category codes must be unique within a project -* Hierarchical relationship with sub-categories via mapping table +- Category codes must be unique within a project +- Hierarchical relationship with sub-categories via mapping table --- @@ -708,20 +698,20 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* UNIQUE KEY (project_id, sub_cat_code) -* INDEX (sort_order) +- PRIMARY KEY (id) +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- UNIQUE KEY (project_id, sub_cat_code) +- INDEX (sort_order) **Relationships**: -* Parent: projects -* Referenced by: contract_drawings, contract_drawing_subcat_cat_maps +- Parent: projects +- Referenced by: contract_drawings, contract_drawing_subcat_cat_maps **Business Rules**: -* Sub-category codes must be unique within a project -* Can be mapped to multiple main categories via mapping table +- Sub-category codes must be unique within a project +- Can be mapped to multiple main categories via mapping table --- @@ -738,24 +728,24 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* **UNIQUE KEY (project_id, sub_cat_id, cat_id)** -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* FOREIGN KEY (sub_cat_id) REFERENCES contract_drawing_sub_cats(id) ON DELETE CASCADE -* FOREIGN KEY (cat_id) REFERENCES contract_drawing_cats(id) ON DELETE CASCADE -* INDEX (sub_cat_id) -* INDEX (cat_id) +- PRIMARY KEY (id) +- **UNIQUE KEY (project_id, sub_cat_id, cat_id)** +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- FOREIGN KEY (sub_cat_id) REFERENCES contract_drawing_sub_cats(id) ON DELETE CASCADE +- FOREIGN KEY (cat_id) REFERENCES contract_drawing_cats(id) ON DELETE CASCADE +- INDEX (sub_cat_id) +- INDEX (cat_id) **Relationships**: -* Parent: projects, contract_drawing_sub_cats, contract_drawing_cats -* Referenced by: contract_drawings +- Parent: projects, contract_drawing_sub_cats, contract_drawing_cats +- Referenced by: contract_drawings **Business Rules**: -* Allows flexible categorization -* One sub-category can belong to multiple main categories -* Composite uniqueness enforced via UNIQUE constraint +- Allows flexible categorization +- One sub-category can belong to multiple main categories +- Composite uniqueness enforced via UNIQUE constraint --- @@ -779,28 +769,28 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* **FOREIGN KEY (map_cat_id) REFERENCES contract_drawing_subcat_cat_maps(id) ON DELETE RESTRICT** -* FOREIGN KEY (volume_id) REFERENCES contract_drawing_volumes(id) ON DELETE RESTRICT -* FOREIGN KEY (updated_by) REFERENCES users(user_id) -* UNIQUE KEY (project_id, condwg_no) -* INDEX (map_cat_id) -* INDEX (volume_id) -* INDEX (deleted_at) +- PRIMARY KEY (id) +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- **FOREIGN KEY (map_cat_id) REFERENCES contract_drawing_subcat_cat_maps(id) ON DELETE RESTRICT** +- FOREIGN KEY (volume_id) REFERENCES contract_drawing_volumes(id) ON DELETE RESTRICT +- FOREIGN KEY (updated_by) REFERENCES users(user_id) +- UNIQUE KEY (project_id, condwg_no) +- INDEX (map_cat_id) +- INDEX (volume_id) +- INDEX (deleted_at) **Relationships**: -* Parent: projects, contract_drawing_subcat_cat_maps, contract_drawing_volumes, users -* Referenced by: shop_drawing_revision_contract_refs, contract_drawing_attachments +- Parent: projects, contract_drawing_subcat_cat_maps, contract_drawing_volumes, users +- Referenced by: shop_drawing_revision_contract_refs, contract_drawing_attachments **Business Rules**: -* Drawing numbers must be unique within a project -* Represents baseline/contract drawings -* Referenced by shop drawings for compliance tracking -* Soft delete preserves history -* **map_cat_id references the mapping table for flexible categorization** +- Drawing numbers must be unique within a project +- Represents baseline/contract drawings +- Referenced by shop drawings for compliance tracking +- Soft delete preserves history +- **map_cat_id references the mapping table for flexible categorization** --- @@ -822,21 +812,21 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* **FOREIGN KEY (project_id) REFERENCES projects(id)** -* UNIQUE (main_category_code) -* INDEX (is_active) -* INDEX (sort_order) +- PRIMARY KEY (id) +- **FOREIGN KEY (project_id) REFERENCES projects(id)** +- UNIQUE (main_category_code) +- INDEX (is_active) +- INDEX (sort_order) **Relationships**: -* **Parent: projects** -* Referenced by: shop_drawings, asbuilt_drawings +- **Parent: projects** +- Referenced by: shop_drawings, asbuilt_drawings **Business Rules**: -* **[CHANGED] Project-specific categories (was global)** -* Typically represents engineering disciplines +- **[CHANGED] Project-specific categories (was global)** +- Typically represents engineering disciplines --- @@ -858,22 +848,22 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* **FOREIGN KEY (project_id) REFERENCES projects(id)** -* UNIQUE (sub_category_code) -* INDEX (is_active) -* INDEX (sort_order) +- PRIMARY KEY (id) +- **FOREIGN KEY (project_id) REFERENCES projects(id)** +- UNIQUE (sub_category_code) +- INDEX (is_active) +- INDEX (sort_order) **Relationships**: -* **Parent: projects** -* Referenced by: shop_drawings, asbuilt_drawings +- **Parent: projects** +- Referenced by: shop_drawings, asbuilt_drawings **Business Rules**: -* **[CHANGED] Project-specific sub-categories (was global)** -* **[REMOVED] No longer hierarchical under main categories** -* Represents specific drawing types or components +- **[CHANGED] Project-specific sub-categories (was global)** +- **[REMOVED] No longer hierarchical under main categories** +- Represents specific drawing types or components --- @@ -895,29 +885,29 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* UNIQUE (drawing_number) -* FOREIGN KEY (project_id) REFERENCES projects(id) -* FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id) -* FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id) -* FOREIGN KEY (updated_by) REFERENCES users(user_id) -* INDEX (project_id) -* INDEX (main_category_id) -* INDEX (sub_category_id) -* INDEX (deleted_at) +- PRIMARY KEY (id) +- UNIQUE (drawing_number) +- FOREIGN KEY (project_id) REFERENCES projects(id) +- FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id) +- FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id) +- FOREIGN KEY (updated_by) REFERENCES users(user_id) +- INDEX (project_id) +- INDEX (main_category_id) +- INDEX (sub_category_id) +- INDEX (deleted_at) **Relationships**: -* Parent: projects, shop_drawing_main_categories, shop_drawing_sub_categories, users -* Children: shop_drawing_revisions +- Parent: projects, shop_drawing_main_categories, shop_drawing_sub_categories, users +- Children: shop_drawing_revisions **Business Rules**: -* Drawing numbers are globally unique across all projects -* Represents contractor shop drawings -* Can have multiple revisions -* Soft delete preserves history -* **[CHANGED] Title moved to shop_drawing_revisions table** +- Drawing numbers are globally unique across all projects +- Represents contractor shop drawings +- Can have multiple revisions +- Soft delete preserves history +- **[CHANGED] Title moved to shop_drawing_revisions table** --- @@ -939,24 +929,24 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (shop_drawing_id) REFERENCES shop_drawings(id) ON DELETE CASCADE -* UNIQUE KEY (shop_drawing_id, revision_number) -* INDEX (revision_date) +- PRIMARY KEY (id) +- FOREIGN KEY (shop_drawing_id) REFERENCES shop_drawings(id) ON DELETE CASCADE +- UNIQUE KEY (shop_drawing_id, revision_number) +- INDEX (revision_date) **Relationships**: -* Parent: shop_drawings -* Referenced by: rfa_items, shop_drawing_revision_contract_refs, shop_drawing_revision_attachments, asbuilt_revision_shop_revisions_refs +- Parent: shop_drawings +- Referenced by: rfa_items, shop_drawing_revision_contract_refs, shop_drawing_revision_attachments, asbuilt_revision_shop_revisions_refs **Business Rules**: -* Revision numbers are sequential starting from 0 -* Each revision can reference multiple contract drawings -* Each revision can have multiple file attachments -* Linked to RFAs for approval tracking -* **[NEW] Title stored at revision level for version-specific naming** -* **[NEW] legacy_drawing_number supports data migration from old systems** +- Revision numbers are sequential starting from 0 +- Each revision can reference multiple contract drawings +- Each revision can have multiple file attachments +- Linked to RFAs for approval tracking +- **[NEW] Title stored at revision level for version-specific naming** +- **[NEW] legacy_drawing_number supports data migration from old systems** --- @@ -971,20 +961,20 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (shop_drawing_revision_id, contract_drawing_id) -* FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (contract_drawing_id) REFERENCES contract_drawings(id) ON DELETE CASCADE -* INDEX (contract_drawing_id) +- PRIMARY KEY (shop_drawing_revision_id, contract_drawing_id) +- FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (contract_drawing_id) REFERENCES contract_drawings(id) ON DELETE CASCADE +- INDEX (contract_drawing_id) **Relationships**: -* Parent: shop_drawing_revisions, contract_drawings +- Parent: shop_drawing_revisions, contract_drawings **Business Rules**: -* Tracks which contract drawings each shop drawing revision is based on -* Ensures compliance with contract specifications -* One shop drawing revision can reference multiple contract drawings +- Tracks which contract drawings each shop drawing revision is based on +- Ensures compliance with contract specifications +- One shop drawing revision can reference multiple contract drawings --- @@ -1006,29 +996,29 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* UNIQUE (drawing_number) -* FOREIGN KEY (project_id) REFERENCES projects(id) -* FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id) -* FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id) -* FOREIGN KEY (updated_by) REFERENCES users(user_id) -* INDEX (project_id) -* INDEX (main_category_id) -* INDEX (sub_category_id) -* INDEX (deleted_at) +- PRIMARY KEY (id) +- UNIQUE (drawing_number) +- FOREIGN KEY (project_id) REFERENCES projects(id) +- FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id) +- FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id) +- FOREIGN KEY (updated_by) REFERENCES users(user_id) +- INDEX (project_id) +- INDEX (main_category_id) +- INDEX (sub_category_id) +- INDEX (deleted_at) **Relationships**: -* Parent: projects, shop_drawing_main_categories, shop_drawing_sub_categories, users -* Children: asbuilt_drawing_revisions +- Parent: projects, shop_drawing_main_categories, shop_drawing_sub_categories, users +- Children: asbuilt_drawing_revisions **Business Rules**: -* Drawing numbers are globally unique across all projects -* Represents final as-built construction drawings -* Can have multiple revisions -* Soft delete preserves history -* Uses same category structure as shop drawings +- Drawing numbers are globally unique across all projects +- Represents final as-built construction drawings +- Can have multiple revisions +- Soft delete preserves history +- Uses same category structure as shop drawings --- @@ -1050,23 +1040,23 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (asbuilt_drawing_id) REFERENCES asbuilt_drawings(id) ON DELETE CASCADE -* UNIQUE KEY (asbuilt_drawing_id, revision_number) -* INDEX (revision_date) +- PRIMARY KEY (id) +- FOREIGN KEY (asbuilt_drawing_id) REFERENCES asbuilt_drawings(id) ON DELETE CASCADE +- UNIQUE KEY (asbuilt_drawing_id, revision_number) +- INDEX (revision_date) **Relationships**: -* Parent: asbuilt_drawings -* Referenced by: asbuilt_revision_shop_revisions_refs, asbuilt_drawing_revision_attachments +- Parent: asbuilt_drawings +- Referenced by: asbuilt_revision_shop_revisions_refs, asbuilt_drawing_revision_attachments **Business Rules**: -* Revision numbers are sequential starting from 0 -* Each revision can reference multiple shop drawing revisions -* Each revision can have multiple file attachments -* Title stored at revision level for version-specific naming -* legacy_drawing_number supports data migration from old systems +- Revision numbers are sequential starting from 0 +- Each revision can reference multiple shop drawing revisions +- Each revision can have multiple file attachments +- Title stored at revision level for version-specific naming +- legacy_drawing_number supports data migration from old systems --- @@ -1081,21 +1071,21 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (asbuilt_drawing_revision_id, shop_drawing_revision_id) -* FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE -* INDEX (shop_drawing_revision_id) +- PRIMARY KEY (asbuilt_drawing_revision_id, shop_drawing_revision_id) +- FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE +- INDEX (shop_drawing_revision_id) **Relationships**: -* Parent: asbuilt_drawing_revisions, shop_drawing_revisions +- Parent: asbuilt_drawing_revisions, shop_drawing_revisions **Business Rules**: -* Tracks which shop drawings each AS Built drawing revision is based on -* Maintains construction document lineage -* One AS Built revision can reference multiple shop drawing revisions -* Supports traceability from final construction to approved shop drawings +- Tracks which shop drawings each AS Built drawing revision is based on +- Maintains construction document lineage +- One AS Built revision can reference multiple shop drawing revisions +- Supports traceability from final construction to approved shop drawings --- @@ -1112,21 +1102,21 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (asbuilt_drawing_revision_id, attachment_id) -* FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE -* INDEX (attachment_id) +- PRIMARY KEY (asbuilt_drawing_revision_id, attachment_id) +- FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE +- INDEX (attachment_id) **Relationships**: -* Parent: asbuilt_drawing_revisions, attachments +- Parent: asbuilt_drawing_revisions, attachments **Business Rules**: -* Each AS Built revision can have multiple file attachments -* File types: PDF (documents), DWG (CAD files), SOURCE (source files), OTHER (miscellaneous) -* One attachment can be marked as main document per revision -* Cascade delete when revision is deleted +- Each AS Built revision can have multiple file attachments +- File types: PDF (documents), DWG (CAD files), SOURCE (source files), OTHER (miscellaneous) +- One attachment can be marked as main document per revision +- Cascade delete when revision is deleted --- @@ -1146,21 +1136,21 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* UNIQUE (code) -* INDEX (is_active) -* INDEX (sort_order) +- PRIMARY KEY (id) +- UNIQUE (code) +- INDEX (is_active) +- INDEX (sort_order) **Relationships**: -* Referenced by: circulations +- Referenced by: circulations **Seed Data**: 4 status codes -* OPEN: Initial status when created -* IN_REVIEW: Under review by recipients -* COMPLETED: All recipients have responded -* CANCELLED: Withdrawn/cancelled +- OPEN: Initial status when created +- IN_REVIEW: Under review by recipients +- COMPLETED: All recipients have responded +- CANCELLED: Withdrawn/cancelled --- @@ -1184,27 +1174,27 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* UNIQUE (correspondence_id) -* FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) -* FOREIGN KEY (organization_id) REFERENCES organizations(id) -* FOREIGN KEY (circulation_status_code) REFERENCES circulation_status_codes(code) -* FOREIGN KEY (created_by_user_id) REFERENCES users(user_id) -* INDEX (organization_id) -* INDEX (circulation_status_code) -* INDEX (created_by_user_id) +- PRIMARY KEY (id) +- UNIQUE (correspondence_id) +- FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) +- FOREIGN KEY (organization_id) REFERENCES organizations(id) +- FOREIGN KEY (circulation_status_code) REFERENCES circulation_status_codes(code) +- FOREIGN KEY (created_by_user_id) REFERENCES users(user_id) +- INDEX (organization_id) +- INDEX (circulation_status_code) +- INDEX (created_by_user_id) **Relationships**: -* Parent: correspondences, organizations, circulation_status_codes, users -* Children: circulation_routings, circulation_attachments +- Parent: correspondences, organizations, circulation_status_codes, users +- Children: circulation_routings, circulation_attachments **Business Rules**: -* Internal document routing within organization -* One-to-one relationship with correspondences -* Tracks document review/approval workflow -* Status progression: OPEN → IN_REVIEW → COMPLETED/CANCELLED +- Internal document routing within organization +- One-to-one relationship with correspondences +- Tracks document review/approval workflow +- Status progression: OPEN → IN_REVIEW → COMPLETED/CANCELLED --- @@ -1222,20 +1212,20 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (correspondence_id) -* FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* INDEX (purpose) +- PRIMARY KEY (correspondence_id) +- FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- INDEX (purpose) **Relationships**: -* Parent: correspondences -* Children: transmittal_items +- Parent: correspondences +- Children: transmittal_items **Business Rules**: -* One-to-one relationship with correspondences -* Transmittal is a correspondence type for forwarding documents -* Contains metadata about the transmission +- One-to-one relationship with correspondences +- Transmittal is a correspondence type for forwarding documents +- Contains metadata about the transmission --- @@ -1253,21 +1243,21 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (transmittal_id) REFERENCES transmittals(correspondence_id) ON DELETE CASCADE -* FOREIGN KEY (item_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* UNIQUE KEY (transmittal_id, item_correspondence_id) -* INDEX (item_correspondence_id) +- PRIMARY KEY (id) +- FOREIGN KEY (transmittal_id) REFERENCES transmittals(correspondence_id) ON DELETE CASCADE +- FOREIGN KEY (item_correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- UNIQUE KEY (transmittal_id, item_correspondence_id) +- INDEX (item_correspondence_id) **Relationships**: -* Parent: transmittals, correspondences +- Parent: transmittals, correspondences **Business Rules**: -* One transmittal can contain multiple documents -* Tracks quantity of physical copies (if applicable) -* Links to any type of correspondence document +- One transmittal can contain multiple documents +- Tracks quantity of physical copies (if applicable) +- Links to any type of correspondence document --- @@ -1277,42 +1267,42 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Purpose**: Central repository for all file attachments in the system -| Column Name | Data Type | Constraints | Description | -| ------------------- | ------------ | --------------------------- | -------------------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique attachment ID | -| original_filename | VARCHAR(255) | NOT NULL | Original filename from upload | -| stored_filename | VARCHAR(255) | NOT NULL | System-generated unique filename | -| file_path | VARCHAR(500) | NOT NULL | Full file path on server (/share/dms-data/) | -| mime_type | VARCHAR(100) | NOT NULL | MIME type (application/pdf, image/jpeg, etc.) | -| file_size | INT | NOT NULL | File size in bytes | -| uploaded_by_user_id | INT | NOT NULL, FK | User who uploaded file | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Upload timestamp | -| is_temporary | BOOLEAN | DEFAULT TRUE | ระบุว่าเป็นไฟล์ชั่วคราว (ยังไม่ได้ Commit) | +| Column Name | Data Type | Constraints | Description | +| ------------------- | ------------ | --------------------------- | -------------------------------------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique attachment ID | +| original_filename | VARCHAR(255) | NOT NULL | Original filename from upload | +| stored_filename | VARCHAR(255) | NOT NULL | System-generated unique filename | +| file_path | VARCHAR(500) | NOT NULL | Full file path on server (/share/dms-data/) | +| mime_type | VARCHAR(100) | NOT NULL | MIME type (application/pdf, image/jpeg, etc.) | +| file_size | INT | NOT NULL | File size in bytes | +| uploaded_by_user_id | INT | NOT NULL, FK | User who uploaded file | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Upload timestamp | +| is_temporary | BOOLEAN | DEFAULT TRUE | ระบุว่าเป็นไฟล์ชั่วคราว (ยังไม่ได้ Commit) | | temp_id\* | VARCHAR(100) | NULL | ID ชั่วคราวสำหรับอ้างอิงตอน Upload Phase 1 (อาจใช้ร่วมกับ id หรือแยกก็ได้) | -| expires_at | DATETIME | NULL | เวลาหมดอายุของไฟล์ Temp (เพื่อให้ Cron Job ลบออก) | -| checksum | VARCHAR(64) | NULL | SHA-256 Checksum สำหรับ Verify File Integrity [Req 3.9.3] | +| expires_at | DATETIME | NULL | เวลาหมดอายุของไฟล์ Temp (เพื่อให้ Cron Job ลบออก) | +| checksum | VARCHAR(64) | NULL | SHA-256 Checksum สำหรับ Verify File Integrity [Req 3.9.3] | **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (uploaded_by_user_id) REFERENCES users(user_id) ON DELETE CASCADE -* INDEX (stored_filename) -* INDEX (mime_type) -* INDEX (uploaded_by_user_id) -* INDEX (created_at) +- PRIMARY KEY (id) +- FOREIGN KEY (uploaded_by_user_id) REFERENCES users(user_id) ON DELETE CASCADE +- INDEX (stored_filename) +- INDEX (mime_type) +- INDEX (uploaded_by_user_id) +- INDEX (created_at) **Relationships**: -* Parent: users -* Referenced by: correspondence_attachments, circulation_attachments, shop_drawing_revision_attachments, contract_drawing_attachments +- Parent: users +- Referenced by: correspondence_attachments, circulation_attachments, shop_drawing_revision_attachments, contract_drawing_attachments **Business Rules**: -* Central storage prevents file duplication -* Stored filename prevents naming conflicts -* File path points to QNAP NAS storage -* Original filename preserved for download -* One file record can be linked to multiple documents +- Central storage prevents file duplication +- Stored filename prevents naming conflicts +- File path points to QNAP NAS storage +- Original filename preserved for download +- One file record can be linked to multiple documents --- @@ -1328,21 +1318,21 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (correspondence_id, attachment_id) -* FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE -* INDEX (attachment_id) -* INDEX (is_main_document) +- PRIMARY KEY (correspondence_id, attachment_id) +- FOREIGN KEY (correspondence_id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE +- INDEX (attachment_id) +- INDEX (is_main_document) **Relationships**: -* Parent: correspondences, attachments +- Parent: correspondences, attachments **Business Rules**: -* One correspondence can have multiple attachments -* One attachment can be linked to multiple correspondences -* is_main_document identifies primary file (typically PDF) +- One correspondence can have multiple attachments +- One attachment can be linked to multiple correspondences +- is_main_document identifies primary file (typically PDF) --- @@ -1358,15 +1348,15 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (circulation_id, attachment_id) -* FOREIGN KEY (circulation_id) REFERENCES circulations(id) ON DELETE CASCADE -* FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE -* INDEX (attachment_id) -* INDEX (is_main_document) +- PRIMARY KEY (circulation_id, attachment_id) +- FOREIGN KEY (circulation_id) REFERENCES circulations(id) ON DELETE CASCADE +- FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE +- INDEX (attachment_id) +- INDEX (is_main_document) **Relationships**: -* Parent: circulations, attachments +- Parent: circulations, attachments --- @@ -1383,22 +1373,22 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (shop_drawing_revision_id, attachment_id) -* FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE -* FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE -* INDEX (attachment_id) -* INDEX (file_type) -* INDEX (is_main_document) +- PRIMARY KEY (shop_drawing_revision_id, attachment_id) +- FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE +- FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE +- INDEX (attachment_id) +- INDEX (file_type) +- INDEX (is_main_document) **Relationships**: -* Parent: shop_drawing_revisions, attachments +- Parent: shop_drawing_revisions, attachments **Business Rules**: -* file_type categorizes drawing file formats -* Typically includes PDF for viewing and DWG for editing -* SOURCE may include native CAD files +- file_type categorizes drawing file formats +- Typically includes PDF for viewing and DWG for editing +- SOURCE may include native CAD files --- @@ -1415,16 +1405,16 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (contract_drawing_id, attachment_id) -* FOREIGN KEY (contract_drawing_id) REFERENCES contract_drawings(id) ON DELETE CASCADE -* FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE -* INDEX (attachment_id) -* INDEX (file_type) -* INDEX (is_main_document) +- PRIMARY KEY (contract_drawing_id, attachment_id) +- FOREIGN KEY (contract_drawing_id) REFERENCES contract_drawings(id) ON DELETE CASCADE +- FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE +- INDEX (attachment_id) +- INDEX (file_type) +- INDEX (is_main_document) **Relationships**: -* Parent: contract_drawings, attachments +- Parent: contract_drawings, attachments --- @@ -1446,20 +1436,20 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE -* UNIQUE KEY (project_id, correspondence_type_id) -* INDEX (is_active) +- PRIMARY KEY (id) +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE +- UNIQUE KEY (project_id, correspondence_type_id) +- INDEX (is_active) **Relationships**: -* Parent: projects, correspondence_types +- Parent: projects, correspondence_types **Business Rules**: -* Defines how document numbers are constructed -* Supports placeholders: {PROJ}, {ORG}, {TYPE}, {YYYY}, {MM}, {#} +- Defines how document numbers are constructed +- Supports placeholders: {PROJ}, {ORG}, {TYPE}, {YYYY}, {MM}, {#} --- @@ -1470,28 +1460,28 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga | Column Name | Data Type | Constraints | Description | | -------------------------- | ----------- | ------------- | ----------------------------------------------- | | project_id | INT | PK, NOT NULL | โครงการ | -| originator_organization_id | INT | PK, NOT NULL | องค์กรผู้ส่ง | -| recipient_organization_id | INT | PK, NOT NULL | องค์กรผู้รับ (0 = no recipient / RFA) | +| originator_organization_id | INT | PK, NOT NULL | องค์กรผู้ส่ง | +| recipient_organization_id | INT | PK, NOT NULL | องค์กรผู้รับ (0 = no recipient / RFA) | | correspondence_type_id | INT | PK, NULL | ประเภทเอกสาร (NULL = default) | -| sub_type_id | INT | PK, DEFAULT 0 | ประเภทย่อย สำหรับ TRANSMITTAL (0 = ไม่ระบุ) | -| rfa_type_id | INT | PK, DEFAULT 0 | ประเภท RFA (0 = ไม่ใช่ RFA) | -| discipline_id | INT | PK, DEFAULT 0 | สาขางาน (0 = ไม่ระบุ) | +| sub_type_id | INT | PK, DEFAULT 0 | ประเภทย่อย สำหรับ TRANSMITTAL (0 = ไม่ระบุ) | +| rfa_type_id | INT | PK, DEFAULT 0 | ประเภท RFA (0 = ไม่ใช่ RFA) | +| discipline_id | INT | PK, DEFAULT 0 | สาขางาน (0 = ไม่ระบุ) | | reset_scope | VARCHAR(20) | PK, NOT NULL | Scope of reset (YEAR_2024, MONTH_2024_01, NONE) | -| last_number | INT | DEFAULT 0 | เลขล่าสุดที่ถูกใช้งานไปแล้ว | +| last_number | INT | DEFAULT 0 | เลขล่าสุดที่ถูกใช้งานไปแล้ว | | version | INT | DEFAULT 0 | Optimistic Lock Version | -| updated_at | DATETIME(6) | ON UPDATE | เวลาที่อัปเดตล่าสุด | +| updated_at | DATETIME(6) | ON UPDATE | เวลาที่อัปเดตล่าสุด | **Indexes**: -* **PRIMARY KEY (project_id, originator_organization_id, recipient_organization_id, correspondence_type_id, sub_type_id, rfa_type_id, discipline_id, reset_scope)** -* INDEX idx_counter_lookup (project_id, correspondence_type_id, reset_scope) -* INDEX idx_counter_org (originator_organization_id, reset_scope) +- **PRIMARY KEY (project_id, originator_organization_id, recipient_organization_id, correspondence_type_id, sub_type_id, rfa_type_id, discipline_id, reset_scope)** +- INDEX idx_counter_lookup (project_id, correspondence_type_id, reset_scope) +- INDEX idx_counter_org (originator_organization_id, reset_scope) **Business Rules**: -* **Composite Primary Key 8 Columns**: เพื่อรองรับการรันเลขที่ซับซ้อนและ Reset Scope ที่หลากหลาย -* **Concurrency Control**: ใช้ Redis Lock หรือ Optimistic Locking (version) -* **Reset Scope**: ใช้ Field `reset_scope` ควบคุมการ Reset แทน `current_year` แบบเดิม +- **Composite Primary Key 8 Columns**: เพื่อรองรับการรันเลขที่ซับซ้อนและ Reset Scope ที่หลากหลาย +- **Concurrency Control**: ใช้ Redis Lock หรือ Optimistic Locking (version) +- **Reset Scope**: ใช้ Field `reset_scope` ควบคุมการ Reset แทน `current_year` แบบเดิม --- @@ -1499,39 +1489,39 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Purpose**: Audit log for document number generation (Debugging & Tracking) -| Column Name | Data Type | Constraints | Description | -| :------------------------- | :----------- | :----------------- | :-------------------------------------- | -| id | INT | PK, AI | ID ของ audit record | -| document_id | INT | NULL, FK | ID ของเอกสารที่สร้างเลขที่ (NULL if failed) | -| document_type | VARCHAR(50) | NULL | ประเภทเอกสาร | -| document_number | VARCHAR(100) | NOT NULL | เลขที่เอกสารที่สร้าง (ผลลัพธ์) | -| operation | ENUM | DEFAULT 'CONFIRM' | RESERVE, CONFIRM, MANUAL_OVERRIDE, etc. | -| status | ENUM | DEFAULT 'RESERVED' | RESERVED, CONFIRMED, CANCELLED, VOID | -| counter_key | JSON | NOT NULL | Counter key ที่ใช้ (JSON 8 fields) | -| reservation_token | VARCHAR(36) | NULL | Token การจอง | -| idempotency_key | VARCHAR(128) | NULL | Idempotency Key from request | +| Column Name | Data Type | Constraints | Description | +| :------------------------- | :----------- | :----------------- | :------------------------------------------ | +| id | INT | PK, AI | ID ของ audit record | +| document_id | INT | NULL, FK | ID ของเอกสารที่สร้างเลขที่ (NULL if failed) | +| document_type | VARCHAR(50) | NULL | ประเภทเอกสาร | +| document_number | VARCHAR(100) | NOT NULL | เลขที่เอกสารที่สร้าง (ผลลัพธ์) | +| operation | ENUM | DEFAULT 'CONFIRM' | RESERVE, CONFIRM, MANUAL_OVERRIDE, etc. | +| status | ENUM | DEFAULT 'RESERVED' | RESERVED, CONFIRMED, CANCELLED, VOID | +| counter_key | JSON | NOT NULL | Counter key ที่ใช้ (JSON 8 fields) | +| reservation_token | VARCHAR(36) | NULL | Token การจอง | +| idempotency_key | VARCHAR(128) | NULL | Idempotency Key from request | | originator_organization_id | INT | NULL | องค์กรผู้ส่ง | | recipient_organization_id | INT | NULL | องค์กรผู้รับ | | template_used | VARCHAR(200) | NOT NULL | Template ที่ใช้ในการสร้าง | -| old_value | TEXT | NULL | Previous value | -| new_value | TEXT | NULL | New value | -| user_id | INT | NULL, FK | ผู้ขอสร้างเลขที่ | -| is_success | BOOLEAN | DEFAULT TRUE | สถานะความสำเร็จ | -| created_at | TIMESTAMP | DEFAULT NOW | วันที่/เวลาที่สร้าง | -| total_duration_ms | INT | NULL | เวลารวมทั้งหมดในการสร้าง (ms) | +| old_value | TEXT | NULL | Previous value | +| new_value | TEXT | NULL | New value | +| user_id | INT | NULL, FK | ผู้ขอสร้างเลขที่ | +| is_success | BOOLEAN | DEFAULT TRUE | สถานะความสำเร็จ | +| created_at | TIMESTAMP | DEFAULT NOW | วันที่/เวลาที่สร้าง | +| total_duration_ms | INT | NULL | เวลารวมทั้งหมดในการสร้าง (ms) | **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (document_id) REFERENCES correspondences(id) ON DELETE CASCADE -* FOREIGN KEY (user_id) REFERENCES users(user_id) -* INDEX (document_id) -* INDEX (user_id) -* INDEX (status) -* INDEX (operation) -* INDEX (document_number) -* INDEX (reservation_token) -* INDEX (created_at) +- PRIMARY KEY (id) +- FOREIGN KEY (document_id) REFERENCES correspondences(id) ON DELETE CASCADE +- FOREIGN KEY (user_id) REFERENCES users(user_id) +- INDEX (document_id) +- INDEX (user_id) +- INDEX (status) +- INDEX (operation) +- INDEX (document_number) +- INDEX (reservation_token) +- INDEX (created_at) --- @@ -1543,20 +1533,20 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga | :------------ | :-------- | :---------- | :--------------------------------------------- | | id | INT | PK, AI | ID ของ error record | | error_type | ENUM | NOT NULL | LOCK_TIMEOUT, VERSION_CONFLICT, DB_ERROR, etc. | -| error_message | TEXT | NULL | ข้อความ error | -| stack_trace | TEXT | NULL | Stack trace สำหรับ debugging | +| error_message | TEXT | NULL | ข้อความ error | +| stack_trace | TEXT | NULL | Stack trace สำหรับ debugging | | context_data | JSON | NULL | Context ของ request | -| user_id | INT | NULL | ผู้ที่เกิด error | -| created_at | TIMESTAMP | DEFAULT NOW | วันที่เกิด error | -| resolved_at | TIMESTAMP | NULL | วันที่แก้ไขแล้ว | +| user_id | INT | NULL | ผู้ที่เกิด error | +| created_at | TIMESTAMP | DEFAULT NOW | วันที่เกิด error | +| resolved_at | TIMESTAMP | NULL | วันที่แก้ไขแล้ว | **Indexes**: -* PRIMARY KEY (id) -* INDEX (error_type) -* INDEX (created_at) -* INDEX (user_id) -* INDEX (resolved_at) +- PRIMARY KEY (id) +- INDEX (error_type) +- INDEX (created_at) +- INDEX (user_id) +- INDEX (resolved_at) --- @@ -1568,26 +1558,26 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga | :--------------------- | :----------- | :--------------- | :----------------------------------- | | id | INT | PK, AI | Unique ID | | token | VARCHAR(36) | UNIQUE, NOT NULL | UUID v4 Reservation Token | -| document_number | VARCHAR(100) | UNIQUE, NOT NULL | เลขที่เอกสารที่จอง | +| document_number | VARCHAR(100) | UNIQUE, NOT NULL | เลขที่เอกสารที่จอง | | document_number_status | ENUM | DEFAULT RESERVED | RESERVED, CONFIRMED, CANCELLED, VOID | -| document_id | INT | NULL, FK | ID ของเอกสาร (เมื่อ Confirm แล้ว) | -| expires_at | DATETIME(6) | NOT NULL | เวลาหมดอายุการจอง | +| document_id | INT | NULL, FK | ID ของเอกสาร (เมื่อ Confirm แล้ว) | +| expires_at | DATETIME(6) | NOT NULL | เวลาหมดอายุการจอง | | project_id | INT | NOT NULL, FK | Project Context | | user_id | INT | NOT NULL, FK | User Context | **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (document_id) REFERENCES correspondence_revisions(id) ON DELETE SET NULL -* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -* FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE -* FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE -* INDEX idx_token (token) -* INDEX idx_status (document_number_status) -* INDEX idx_status_expires (document_number_status, expires_at) -* INDEX idx_document_id (document_id) -* INDEX idx_user_id (user_id) -* INDEX idx_reserved_at (reserved_at) +- PRIMARY KEY (id) +- FOREIGN KEY (document_id) REFERENCES correspondence_revisions(id) ON DELETE SET NULL +- FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +- FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE +- FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE +- INDEX idx_token (token) +- INDEX idx_status (document_number_status) +- INDEX idx_status_expires (document_number_status, expires_at) +- INDEX idx_document_id (document_id) +- INDEX idx_user_id (user_id) +- INDEX idx_reserved_at (reserved_at) --- @@ -1597,23 +1587,23 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Purpose**: เก็บแม่แบบ (Template) ของ Workflow (Definition / DSL) -| Column Name | Data Type | Constraints | Description | -| :------------ | :---------- | :----------- | :------------------------------------- | -| id | CHAR(36) | PK, UUID | Unique Workflow Definition ID | -| workflow_code | VARCHAR(50) | NOT NULL | รหัส Workflow (เช่น RFA_FLOW_V1) | -| version | INT | DEFAULT 1 | หมายเลข Version | -| description | TEXT | NULL | คำอธิบาย Workflow | +| Column Name | Data Type | Constraints | Description | +| :------------ | :---------- | :----------- | :---------------------------------------- | +| id | CHAR(36) | PK, UUID | Unique Workflow Definition ID | +| workflow_code | VARCHAR(50) | NOT NULL | รหัส Workflow (เช่น RFA_FLOW_V1) | +| version | INT | DEFAULT 1 | หมายเลข Version | +| description | TEXT | NULL | คำอธิบาย Workflow | | dsl | JSON | NOT NULL | นิยาม Workflow ต้นฉบับ (YAML/JSON Format) | -| compiled | JSON | NOT NULL | โครงสร้าง Execution Tree ที่ Compile แล้ว | -| is_active | BOOLEAN | DEFAULT TRUE | สถานะการใช้งาน | -| created_at | TIMESTAMP | DEFAULT NOW | วันที่สร้าง | -| updated_at | TIMESTAMP | ON UPDATE | วันที่แก้ไขล่าสุด | +| compiled | JSON | NOT NULL | โครงสร้าง Execution Tree ที่ Compile แล้ว | +| is_active | BOOLEAN | DEFAULT TRUE | สถานะการใช้งาน | +| created_at | TIMESTAMP | DEFAULT NOW | วันที่สร้าง | +| updated_at | TIMESTAMP | ON UPDATE | วันที่แก้ไขล่าสุด | **Indexes**: -* PRIMARY KEY (id) -* UNIQUE KEY (workflow_code, version) -* INDEX (is_active) +- PRIMARY KEY (id) +- UNIQUE KEY (workflow_code, version) +- INDEX (is_active) --- @@ -1624,21 +1614,21 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga | Column Name | Data Type | Constraints | Description | | :------------ | :---------- | :--------------- | :--------------------------------------------- | | id | CHAR(36) | PK, UUID | Unique Instance ID | -| definition_id | CHAR(36) | FK, NOT NULL | อ้างอิง Definition ที่ใช้ | +| definition_id | CHAR(36) | FK, NOT NULL | อ้างอิง Definition ที่ใช้ | | entity_type | VARCHAR(50) | NOT NULL | ประเภทเอกสาร (rfa_revision, correspondence...) | | entity_id | VARCHAR(50) | NOT NULL | ID ของเอกสาร | -| current_state | VARCHAR(50) | NOT NULL | สถานะปัจจุบัน | +| current_state | VARCHAR(50) | NOT NULL | สถานะปัจจุบัน | | status | ENUM | DEFAULT 'ACTIVE' | ACTIVE, COMPLETED, CANCELLED, TERMINATED | -| context | JSON | NULL | ตัวแปร Context สำหรับตัดสินใจ | -| created_at | TIMESTAMP | DEFAULT NOW | เวลาที่สร้าง | -| updated_at | TIMESTAMP | ON UPDATE | เวลาที่อัปเดตล่าสุด | +| context | JSON | NULL | ตัวแปร Context สำหรับตัดสินใจ | +| created_at | TIMESTAMP | DEFAULT NOW | เวลาที่สร้าง | +| updated_at | TIMESTAMP | ON UPDATE | เวลาที่อัปเดตล่าสุด | **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (definition_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE -* INDEX (entity_type, entity_id) -* INDEX (current_state) +- PRIMARY KEY (id) +- FOREIGN KEY (definition_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE +- INDEX (entity_type, entity_id) +- INDEX (current_state) --- @@ -1646,24 +1636,24 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Purpose**: เก็บประวัติการดำเนินการในแต่ละ Step (Audit Trail) -| Column Name | Data Type | Constraints | Description | -| :---------------- | :---------- | :----------- | :-------------------- | -| id | CHAR(36) | PK, UUID | Unique ID | -| instance_id | CHAR(36) | FK, NOT NULL | อ้างอิง Instance | -| from_state | VARCHAR(50) | NOT NULL | สถานะต้นทาง | -| to_state | VARCHAR(50) | NOT NULL | สถานะปลายทาง | -| action | VARCHAR(50) | NOT NULL | Action ที่กระทำ | -| action_by_user_id | INT | FK, NULL | User ID ผู้กระทำ | -| comment | TEXT | NULL | ความเห็น | +| Column Name | Data Type | Constraints | Description | +| :---------------- | :---------- | :----------- | :------------------------ | +| id | CHAR(36) | PK, UUID | Unique ID | +| instance_id | CHAR(36) | FK, NOT NULL | อ้างอิง Instance | +| from_state | VARCHAR(50) | NOT NULL | สถานะต้นทาง | +| to_state | VARCHAR(50) | NOT NULL | สถานะปลายทาง | +| action | VARCHAR(50) | NOT NULL | Action ที่กระทำ | +| action_by_user_id | INT | FK, NULL | User ID ผู้กระทำ | +| comment | TEXT | NULL | ความเห็น | | metadata | JSON | NULL | Snapshot ข้อมูล ณ ขณะนั้น | -| created_at | TIMESTAMP | DEFAULT NOW | เวลาที่กระทำ | +| created_at | TIMESTAMP | DEFAULT NOW | เวลาที่กระทำ | **Indexes**: -* PRIMARY KEY (id) -* FOREIGN KEY (instance_id) REFERENCES workflow_instances(id) ON DELETE CASCADE -* INDEX (instance_id) -* INDEX (action_by_user_id) +- PRIMARY KEY (id) +- FOREIGN KEY (instance_id) REFERENCES workflow_instances(id) ON DELETE CASCADE +- INDEX (instance_id) +- INDEX (action_by_user_id) --- @@ -1673,18 +1663,17 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Purpose**: เก็บ Schema สำหรับ Validate JSON Columns (Req 3.12) -| Column Name | Data Type | Constraints | Description | -| :---------------- | :----------- | :----------- | :------------------------------- | -| id | INT | PK, AI | Unique ID | -| schema_code | VARCHAR(100) | NOT NULL | รหัส Schema (เช่น RFA_DWG) | -| version | INT | DEFAULT 1 | เวอร์ชันของ Schema | +| Column Name | Data Type | Constraints | Description | +| :---------------- | :----------- | :----------- | :---------------------------------- | +| id | INT | PK, AI | Unique ID | +| schema_code | VARCHAR(100) | NOT NULL | รหัส Schema (เช่น RFA_DWG) | +| version | INT | DEFAULT 1 | เวอร์ชันของ Schema | | table_name | VARCHAR(100) | NOT NULL | ชื่อตารางเป้าหมาย | -| schema_definition | JSON | NOT NULL | JSON Schema Definition | +| schema_definition | JSON | NOT NULL | JSON Schema Definition | | ui_schema | JSON | NULL | โครงสร้าง UI Schema สำหรับ Frontend | | virtual_columns | JSON | NULL | Config สำหรับสร้าง Virtual Columns | -| migration_script | JSON | NULL | Script สำหรับแปลงข้อมูล | -| is_active | BOOLEAN | DEFAULT TRUE | สถานะการใช้งาน | - +| migration_script | JSON | NULL | Script สำหรับแปลงข้อมูล | +| is_active | BOOLEAN | DEFAULT TRUE | สถานะการใช้งาน | --- @@ -1708,14 +1697,15 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (audit_id, created_at) -- **Partition Key** -* INDEX idx_audit_user (user_id) -* INDEX idx_audit_action (action) -* INDEX idx_audit_entity (entity_type, entity_id) -* INDEX idx_audit_created (created_at) +- PRIMARY KEY (audit_id, created_at) -- **Partition Key** +- INDEX idx_audit_user (user_id) +- INDEX idx_audit_action (action) +- INDEX idx_audit_entity (entity_type, entity_id) +- INDEX idx_audit_created (created_at) **Partitioning**: -* **PARTITION BY RANGE (YEAR(created_at))**: แบ่ง Partition รายปี เพื่อประสิทธิภาพในการเก็บข้อมูลระยะยาว + +- **PARTITION BY RANGE (YEAR(created_at))**: แบ่ง Partition รายปี เพื่อประสิทธิภาพในการเก็บข้อมูลระยะยาว --- @@ -1737,14 +1727,15 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Indexes**: -* PRIMARY KEY (id, created_at) -- **Partition Key** -* INDEX idx_notif_user (user_id) -* INDEX idx_notif_type (notification_type) -* INDEX idx_notif_read (is_read) -* INDEX idx_notif_created (created_at) +- PRIMARY KEY (id, created_at) -- **Partition Key** +- INDEX idx_notif_user (user_id) +- INDEX idx_notif_type (notification_type) +- INDEX idx_notif_read (is_read) +- INDEX idx_notif_created (created_at) **Partitioning**: -* **PARTITION BY RANGE (YEAR(created_at))**: แบ่ง Partition รายปี + +- **PARTITION BY RANGE (YEAR(created_at))**: แบ่ง Partition รายปี --- @@ -1820,26 +1811,26 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga ### 14.1 Soft Delete Policy -* **Tables with `deleted_at`**: - * users - * organizations - * projects - * contracts - * correspondences - * rfas - * shop_drawings - * contract_drawings -* **Rule**: Records are never physically deleted. `deleted_at` is set to timestamp. -* **Query Rule**: All standard queries MUST include `WHERE deleted_at IS NULL`. +- **Tables with `deleted_at`**: + - users + - organizations + - projects + - contracts + - correspondences + - rfas + - shop_drawings + - contract_drawings +- **Rule**: Records are never physically deleted. `deleted_at` is set to timestamp. +- **Query Rule**: All standard queries MUST include `WHERE deleted_at IS NULL`. ### 14.2 Foreign Key Cascades -* **ON DELETE CASCADE**: - * Used for child tables that cannot exist without parent (e.g., `correspondence_revisions`, `rfa_revisions`, `correspondence_attachments`). -* **ON DELETE RESTRICT**: - * Used for master data references to prevent accidental deletion of used data (e.g., `correspondence_types`, `organizations`). -* **ON DELETE SET NULL**: - * Used for optional references (e.g., `created_by`, `originator_id`). +- **ON DELETE CASCADE**: + - Used for child tables that cannot exist without parent (e.g., `correspondence_revisions`, `rfa_revisions`, `correspondence_attachments`). +- **ON DELETE RESTRICT**: + - Used for master data references to prevent accidental deletion of used data (e.g., `correspondence_types`, `organizations`). +- **ON DELETE SET NULL**: + - Used for optional references (e.g., `created_by`, `originator_id`). --- @@ -1847,15 +1838,15 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga ### 15.1 Row-Level Security (RLS) Logic -* **Organization Scope**: Users can only see documents where `originator_id` OR `recipient_organization_id` matches their organization. -* **Project Scope**: Users can only see documents within projects they are assigned to. -* **Confidentiality**: Documents marked `is_confidential` are visible ONLY to specific roles or users. +- **Organization Scope**: Users can only see documents where `originator_id` OR `recipient_organization_id` matches their organization. +- **Project Scope**: Users can only see documents within projects they are assigned to. +- **Confidentiality**: Documents marked `is_confidential` are visible ONLY to specific roles or users. ### 15.2 Role-Based Access Control (RBAC) -* **Permissions** are granular (e.g., `correspondence.view`, `correspondence.create`). -* **Roles** aggregate permissions (e.g., `Document Controller` = `view` + `create` + `edit`). -* **Assignments** link Users to Roles within a Context (Global, Project, or Organization). +- **Permissions** are granular (e.g., `correspondence.view`, `correspondence.create`). +- **Roles** aggregate permissions (e.g., `Document Controller` = `view` + `create` + `edit`). +- **Assignments** link Users to Roles within a Context (Global, Project, or Organization). --- @@ -1864,25 +1855,25 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga ### 16.1 Initial Seeding (V1.7.0) 1. **Master Data**: - * `organizations`: Owner, Consultant, Contractor - * `projects`: LCBP3 - * `correspondence_types`: LETTER, MEMO, TRANSMITTAL, RFA - * `rfa_types`: DWG, MAT, DOC, RFI - * `rfa_status_codes`: DFT, PEND, APPR, REJ - * `disciplines`: GEN, STR, ARC, MEP + - `organizations`: Owner, Consultant, Contractor + - `projects`: LCBP3 + - `correspondence_types`: LETTER, MEMO, TRANSMITTAL, RFA + - `rfa_types`: DWG, MAT, DOC, RFI + - `rfa_status_codes`: DFT, PEND, APPR, REJ + - `disciplines`: GEN, STR, ARC, MEP 2. **System Users**: - * `admin`: Super Admin - * `system`: System Bot for automated tasks + - `admin`: Super Admin + - `system`: System Bot for automated tasks ### 16.2 Migration Strategy -* **Schema Migration**: Use TypeORM Migrations or raw SQL scripts (versioned). -* **Data Migration**: - * **V1.6.0 -> V1.7.0**: - * Run SQL script `9_lcbp3_v1_7_0.sql` - * Migrate `document_number_counters` to 8-col composite PK. - * Initialize `document_number_reservations`. - * Update `json_schemas` with new columns. +- **Schema Migration**: Use TypeORM Migrations or raw SQL scripts (versioned). +- **Data Migration**: + - **V1.6.0 -> V1.7.0**: + - Run SQL script `9_lcbp3_v1_7_0.sql` + - Migrate `document_number_counters` to 8-col composite PK. + - Initialize `document_number_reservations`. + - Update `json_schemas` with new columns. --- @@ -1890,29 +1881,29 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga ### 17.1 Database Maintenance -* **Daily**: Incremental Backup. -* **Weekly**: Full Backup + `OPTIMIZE TABLE` for heavy tables (`audit_logs`, `notifications`). -* **Monthly**: Archive old `audit_logs` partitions to cold storage. +- **Daily**: Incremental Backup. +- **Weekly**: Full Backup + `OPTIMIZE TABLE` for heavy tables (`audit_logs`, `notifications`). +- **Monthly**: Archive old `audit_logs` partitions to cold storage. ### 17.2 Health Checks -* Monitor `document_number_errors` for numbering failures. -* Monitor `workflow_instances` for stuck workflows (`status = ' IN_PROGRESS '` > 7 days). -* Check `document_number_counters` for gaps or resets. +- Monitor `document_number_errors` for numbering failures. +- Monitor `workflow_instances` for stuck workflows (`status = ' IN_PROGRESS '` > 7 days). +- Check `document_number_counters` for gaps or resets. --- ## **18. 📖 Glossary (คำศัพท์)** -* **RFA**: Request for Approval (เอกสารขออนุมัติ) -* **Transmittal**: Document Transmittal Sheet (ใบนำส่งเอกสาร) -* **Shop Drawing**: แบบก่อสร้างที่ผู้รับเหมาจัดทำ -* **Contract Drawing**: แบบสัญญา (แบบตั้งต้น) -* **Revision**: ฉบับแก้ไข (0, 1, 2, A, B, C) -* **Originator**: ผู้จัดทำ/ผู้ส่งเอกสาร -* **Recipient**: ผู้รับเอกสาร -* **Workflow**: กระบวนการทำงาน/อนุมัติ -* **Discipline**: สาขางาน (เช่น โยธา, สถาปัตย์, ไฟฟ้า) +- **RFA**: Request for Approval (เอกสารขออนุมัติ) +- **Transmittal**: Document Transmittal Sheet (ใบนำส่งเอกสาร) +- **Shop Drawing**: แบบก่อสร้างที่ผู้รับเหมาจัดทำ +- **Contract Drawing**: แบบสัญญา (แบบตั้งต้น) +- **Revision**: ฉบับแก้ไข (0, 1, 2, A, B, C) +- **Originator**: ผู้จัดทำ/ผู้ส่งเอกสาร +- **Recipient**: ผู้รับเอกสาร +- **Workflow**: กระบวนการทำงาน/อนุมัติ +- **Discipline**: สาขางาน (เช่น โยธา, สถาปัตย์, ไฟฟ้า) --- diff --git a/specs/99-archives/docs/0.html b/specs/99-archives/docs/0.html index c873072..412bba9 100644 --- a/specs/99-archives/docs/0.html +++ b/specs/99-archives/docs/0.html @@ -1,483 +1,486 @@ - + - - - + + + LCBP3-DMS V1.4.1 Project Infographic - + - - - + +
-
- -
-
-

Project Overview: LCBP3 Document Management System (V1.4.1)

-
-
-

- นี่คือภาพรวมของระบบบริหารจัดการเอกสารโครงการ (DMS) V1.4.1 - ที่กำลังพัฒนาสำหรับโครงการแหลมฉบังเฟส 3 (LCBP3) - เป้าหมายหลักคือการสร้างเว็บแอปพลิเคชั่นที่ทันสมัย ปลอดภัย - และมีประสิทธิภาพสูงเพื่อจัดการและควบคุมการสื่อสารด้วยเอกสารที่ซับซ้อน - ลดการใช้กระดาษ และเพิ่มความสะดวกในการทำงานร่วมกันระหว่างองค์กร -

-
-
- -
-

Key Performance Indicators (KPIs)

-
-
-

API Response Time

-

< 200ms

-

(90th Percentile)

-
-
-

Search Performance

-

< 500ms

-

(Elasticsearch)

-
-
-

File Upload (50MB)

-

< 30s

-

(Inc. Virus Scan)

-
-
-

Cache Hit Ratio

-

> 80%

-

(Redis)

-
-
-
- -
-
-

System Architecture & Technology Stack

-
-
-

- สถาปัตยกรรมระบบเป็นแบบ API-First ที่ทำงานบน QNAP Container Station (Docker) - โดยมีการแบ่งส่วนบริการ (Services) อย่างชัดเจน - เพื่อความสะดวกในการจัดการและบำรุงรักษา -

- -
-
-
Internet User
- -
-
QNAP (WAN)
- -
-
Nginx Proxy Manager
-
- -
- -
-
-

Public Facing Services (ผ่าน NPM)

-
-
Frontend (Next.js)
-
Backend (NestJS)
-
Gitea (Git)
-
n8n (Automation)
-
-
- -
-

Internal Services (Backend เรียกใช้)

-
-
MariaDB (Database)
-
Redis (Cache)
-
Elasticsearch (Search)
-
ClamAV (Virus Scan)
-
-
-
-
-
-
- -
-
-
-

Development Roadmap

-
-
-

- แผนการพัฒนาถูกแบ่งออกเป็น Phase (Backend) และ Sprints (Frontend) - เพื่อให้สามารถส่งมอบงานได้อย่างต่อเนื่องและเป็นระบบ -

-
-
-

Backend (NestJS)

-
-
-
-

Phase 0-1: Setup & Core

-

Infrastructure, DB Schema, ORM

-
-
-
-

Phase 2-3: Auth & RBAC

-

JWT, Passport, CASL 4-Level

-
-
-
-

Phase 4-5: Core Features

-

Document Upload, RFA Workflow

-
-
-
-

Phase 6-8: Integration & Deploy

-

Search, Cache, Notification, Deploy

-
-
-
-
-

Frontend (Next.js)

-
-
-
-

Sprint 1-2: Setup & Auth

-

shadcn/ui, NextAuth, Layout

-
-
-
-

Sprint 3: Dashboard

-

Charts (Recharts), KPIs

-
-
-
-

Sprint 4-5: Document Module

-

TanStack Table, Upload, Search

-
-
-
-

Sprint 6-7: Workflow & Deploy

-

RFA Forms, Testing, Deploy

-
-
-
-
-
-
- -
-
-

Feature Focus: 4-Level RBAC

-
-
-

- ระบบควบคุมสิทธิ์ (RBAC) เป็นหัวใจสำคัญของความปลอดภัย - โดยใช้สถาปัตยกรรม 4 ระดับ (4-Level) เพื่อการควบคุมที่ละเอียดสูงสุด -

-
- 🌍 Level 1: Global -

(Super Admin, System Settings)

- -
- 🏢 Level 2: Organization -

(Org Admin, Manage Users & Projects)

- -
- 🏗️ Level 3: Project -

(Project Manager, View All Project Docs)

- -
- 📄 Level 4: Contract -

(Contractor, Access Own Contract Docs Only)

-
-
-
-
-
-
+
+
+

Project Overview: LCBP3 Document Management System (V1.4.1)

+
+

+ นี่คือภาพรวมของระบบบริหารจัดการเอกสารโครงการ (DMS) V1.4.1 ที่กำลังพัฒนาสำหรับโครงการแหลมฉบังเฟส 3 (LCBP3) + เป้าหมายหลักคือการสร้างเว็บแอปพลิเคชั่นที่ทันสมัย ปลอดภัย + และมีประสิทธิภาพสูงเพื่อจัดการและควบคุมการสื่อสารด้วยเอกสารที่ซับซ้อน ลดการใช้กระดาษ + และเพิ่มความสะดวกในการทำงานร่วมกันระหว่างองค์กร +

+
+
-
-
-

Document Statistics (Mockup Data)

+
+

Key Performance Indicators (KPIs)

+
+
+

API Response Time

+

< 200ms

+

(90th Percentile)

+
+
+

Search Performance

+

< 500ms

+

(Elasticsearch)

+
+
+

File Upload (50MB)

+

< 30s

+

(Inc. Virus Scan)

+
+
+

Cache Hit Ratio

+

> 80%

+

(Redis)

+
+
+
+ +
+
+

System Architecture & Technology Stack

+
+
+

+ สถาปัตยกรรมระบบเป็นแบบ API-First ที่ทำงานบน QNAP Container Station (Docker) โดยมีการแบ่งส่วนบริการ + (Services) อย่างชัดเจน เพื่อความสะดวกในการจัดการและบำรุงรักษา +

+ +
+
+
Internet User
+ +
+
QNAP (WAN)
+ +
+
Nginx Proxy Manager
-
-

- Dashboard จะแสดงสถิติเอกสารแบบ Real-time - (อ้างอิงจาก View: `v_document_statistics` ในฐานข้อมูล) - เพื่อช่วยในการติดตามและบริหารจัดการโครงการ -

-
-
-

เอกสารตามประเภท (By Type)

-

- แสดงจำนวนเอกสารทั้งหมดโดยแบ่งตามประเภทหลัก - ช่วยให้เห็นภาพรวมของเอกสารในระบบ -

-
- -
-
-
-

สถานะเอกสาร (By Status)

-

- แสดงสัดส่วนของสถานะเอกสารในปัจจุบัน - (เช่น ร่าง, รออนุมัติ, อนุมัติแล้ว) เพื่อติดตาม Workflow -

-
- -
-
+ +
+ +
+
+

Public Facing Services (ผ่าน NPM)

+
+
Frontend (Next.js)
+
Backend (NestJS)
+
Gitea (Git)
+
n8n (Automation)
+
+ +
+

+ Internal Services (Backend เรียกใช้) +

+
+
MariaDB (Database)
+
Redis (Cache)
+
Elasticsearch (Search)
+
ClamAV (Virus Scan)
+
+
+
+
+
+ +
+
+
+

Development Roadmap

+
+
+

+ แผนการพัฒนาถูกแบ่งออกเป็น Phase (Backend) และ Sprints (Frontend) + เพื่อให้สามารถส่งมอบงานได้อย่างต่อเนื่องและเป็นระบบ +

+
+
+

Backend (NestJS)

+
+
+
+

Phase 0-1: Setup & Core

+

Infrastructure, DB Schema, ORM

+
+
+
+

Phase 2-3: Auth & RBAC

+

JWT, Passport, CASL 4-Level

+
+
+
+

Phase 4-5: Core Features

+

Document Upload, RFA Workflow

+
+
+
+

Phase 6-8: Integration & Deploy

+

Search, Cache, Notification, Deploy

+
+
+
+
+

Frontend (Next.js)

+
+
+
+

Sprint 1-2: Setup & Auth

+

shadcn/ui, NextAuth, Layout

+
+
+
+

Sprint 3: Dashboard

+

Charts (Recharts), KPIs

+
+
+
+

Sprint 4-5: Document Module

+

TanStack Table, Upload, Search

+
+
+
+

Sprint 6-7: Workflow & Deploy

+

RFA Forms, Testing, Deploy

+
+
+
+
+
+
+
+

Feature Focus: 4-Level RBAC

+
+
+

+ ระบบควบคุมสิทธิ์ (RBAC) เป็นหัวใจสำคัญของความปลอดภัย โดยใช้สถาปัตยกรรม 4 ระดับ (4-Level) + เพื่อการควบคุมที่ละเอียดสูงสุด +

+
+ 🌍 Level 1: Global +

(Super Admin, System Settings)

+ +
+ 🏢 Level 2: Organization +

(Org Admin, Manage Users & Projects)

+ +
+ 🏗️ Level 3: Project +

(Project Manager, View All Project Docs)

+ +
+ 📄 Level 4: Contract +

(Contractor, Access Own Contract Docs Only)

+
+
+
+
+
+
+
+ +
+
+

Document Statistics (Mockup Data)

+
+
+

+ Dashboard จะแสดงสถิติเอกสารแบบ Real-time (อ้างอิงจาก View: `v_document_statistics` ในฐานข้อมูล) + เพื่อช่วยในการติดตามและบริหารจัดการโครงการ +

+
+
+

เอกสารตามประเภท (By Type)

+

+ แสดงจำนวนเอกสารทั้งหมดโดยแบ่งตามประเภทหลัก ช่วยให้เห็นภาพรวมของเอกสารในระบบ +

+
+ +
+
+
+

สถานะเอกสาร (By Status)

+

+ แสดงสัดส่วนของสถานะเอกสารในปัจจุบัน (เช่น ร่าง, รออนุมัติ, อนุมัติแล้ว) เพื่อติดตาม Workflow +

+
+ +
+
+
+
+
- LCBP3-DMS V1.4.1 Infographic | Generated on: + LCBP3-DMS V1.4.1 Infographic | Generated on:
- - \ No newline at end of file + + diff --git a/specs/99-archives/docs/20251224-document-numbering-summary.md b/specs/99-archives/docs/20251224-document-numbering-summary.md index b6fe249..82f4867 100644 --- a/specs/99-archives/docs/20251224-document-numbering-summary.md +++ b/specs/99-archives/docs/20251224-document-numbering-summary.md @@ -36,6 +36,7 @@ flowchart TB ## 📁 Backend Structure ### Module Location + `backend/src/modules/document-numbering/` | Directory | Files | Description | @@ -87,6 +88,7 @@ flowchart TB | `audit-logs-table.tsx` | Audit logs table | ### Admin Pages + - `app/(admin)/admin/numbering/` - Template management - `app/(admin)/admin/system-logs/numbering/` - System logs @@ -96,13 +98,13 @@ flowchart TB ### 5 Tables -| Table | Purpose | Key Feature | -| ------------------------------ | ------------------------- | ------------------------------------------- | -| `document_number_formats` | Template รูปแบบเลขที่เอกสาร | Unique per (project, correspondence_type) | -| `document_number_counters` | Running Number Counter | **8-Column Composite PK** + Optimistic Lock | -| `document_number_audit` | Audit Trail สำหรับทุกการสร้าง | เก็บ ≥ 7 ปี | -| `document_number_errors` | Error Log | 5 Error Types | -| `document_number_reservations` | **Two-Phase Commit** | Reserve → Confirm Pattern | +| Table | Purpose | Key Feature | +| ------------------------------ | ----------------------------- | ------------------------------------------- | +| `document_number_formats` | Template รูปแบบเลขที่เอกสาร | Unique per (project, correspondence_type) | +| `document_number_counters` | Running Number Counter | **8-Column Composite PK** + Optimistic Lock | +| `document_number_audit` | Audit Trail สำหรับทุกการสร้าง | เก็บ ≥ 7 ปี | +| `document_number_errors` | Error Log | 5 Error Types | +| `document_number_reservations` | **Two-Phase Commit** | Reserve → Confirm Pattern | --- @@ -123,12 +125,12 @@ PRIMARY KEY ( ### Reset Scope Values -| Value | Description | -| --------------- | -------------------------------- | +| Value | Description | +| --------------- | ----------------------------------- | | `YEAR_XXXX` | Reset ทุกปี เช่น `YEAR_2024` | | `MONTH_XXXX_XX` | Reset ทุกเดือน เช่น `MONTH_2024_01` | -| `CONTRACT_XXXX` | Reset ต่อสัญญา | -| `NONE` | ไม่ Reset | +| `CONTRACT_XXXX` | Reset ต่อสัญญา | +| `NONE` | ไม่ Reset | ### Constraints @@ -150,11 +152,11 @@ CONSTRAINT chk_reset_scope_format CHECK ( | Rule | Description | | ----------------------------- | ---------------------------------------------------- | -| **Uniqueness** | เลขที่เอกสารห้ามซ้ำกันภายใน Project | -| **Sequence Reset** | Reset ตาม `reset_scope` (ปกติ Reset ต่อปี) | -| **Idempotency** | ใช้ `Idempotency-Key` header ป้องกันการสร้างซ้ำ | +| **Uniqueness** | เลขที่เอกสารห้ามซ้ำกันภายใน Project | +| **Sequence Reset** | Reset ตาม `reset_scope` (ปกติ Reset ต่อปี) | +| **Idempotency** | ใช้ `Idempotency-Key` header ป้องกันการสร้างซ้ำ | | **Race Condition Prevention** | Redis Lock (Primary) + DB Optimistic Lock (Fallback) | -| **Format Fallback** | ใช้ Default Format ถ้าไม่มี Specific Format | +| **Format Fallback** | ใช้ Default Format ถ้าไม่มี Specific Format | ### 2️⃣ Two-Phase Commit (Reserve → Confirm) @@ -168,12 +170,12 @@ stateDiagram-v2 CANCELLED --> [*] ``` -| Status | Description | -| ----------- | ----------------------------------- | +| Status | Description | +| ----------- | -------------------------------------- | | `RESERVED` | จองแล้ว รอ Confirm (หมดอายุใน 15 นาที) | -| `CONFIRMED` | ยืนยันแล้ว ใช้งานจริง | -| `CANCELLED` | ยกเลิก (User/System/Timeout) | -| `VOID` | Admin Void (ยกเลิกเลขที่หลัง Confirm) | +| `CONFIRMED` | ยืนยันแล้ว ใช้งานจริง | +| `CANCELLED` | ยกเลิก (User/System/Timeout) | +| `VOID` | Admin Void (ยกเลิกเลขที่หลัง Confirm) | ### 3️⃣ Format Template Tokens @@ -265,26 +267,26 @@ sequenceDiagram ### Public (`/document-numbering`) -| Method | Endpoint | Permission | Description | -| ------ | --------------- | ------------------------ | --------------------------- | +| Method | Endpoint | Permission | Description | +| ------ | --------------- | ------------------------ | ------------------------------ | | POST | `/preview` | `correspondence.read` | Preview เลขที่ (ไม่ increment) | | GET | `/sequences` | `correspondence.read` | ดู Counter ทั้งหมด | -| GET | `/logs/audit` | `system.view_logs` | Audit Logs | -| GET | `/logs/errors` | `system.view_logs` | Error Logs | -| PATCH | `/counters/:id` | `system.manage_settings` | Update Counter (Deprecated) | +| GET | `/logs/audit` | `system.view_logs` | Audit Logs | +| GET | `/logs/errors` | `system.view_logs` | Error Logs | +| PATCH | `/counters/:id` | `system.manage_settings` | Update Counter (Deprecated) | ### Admin (`/admin/document-numbering`) | Method | Endpoint | Description | | ------ | ------------------------ | --------------------------- | -| GET | `/templates` | ดู Templates ทั้งหมด | -| GET | `/templates?projectId=X` | ดู Templates ตาม Project | -| POST | `/templates` | สร้าง/แก้ไข Template | +| GET | `/templates` | ดู Templates ทั้งหมด | +| GET | `/templates?projectId=X` | ดู Templates ตาม Project | +| POST | `/templates` | สร้าง/แก้ไข Template | | DELETE | `/templates/:id` | ลบ Template | | GET | `/metrics` | Audit + Error Logs combined | | POST | `/manual-override` | Override Counter Value | -| POST | `/void-and-replace` | Void + สร้างเลขใหม่ | -| POST | `/cancel` | ยกเลิกเลขที่ | +| POST | `/void-and-replace` | Void + สร้างเลขใหม่ | +| POST | `/cancel` | ยกเลิกเลขที่ | | POST | `/bulk-import` | Import Counters จาก Legacy | --- @@ -293,26 +295,26 @@ sequenceDiagram ### Audit Log Operations -| Operation | Description | -| ----------------- | ------------------ | -| `RESERVE` | จองเลขที่ | -| `CONFIRM` | ยืนยันการใช้เลขที่ | +| Operation | Description | +| ----------------- | ------------------- | +| `RESERVE` | จองเลขที่ | +| `CONFIRM` | ยืนยันการใช้เลขที่ | | `MANUAL_OVERRIDE` | Admin แก้ไข Counter | -| `VOID_REPLACE` | Void และสร้างใหม่ | -| `CANCEL` | ยกเลิกเลขที่ | +| `VOID_REPLACE` | Void และสร้างใหม่ | +| `CANCEL` | ยกเลิกเลขที่ | ### Audit Log Fields -| Field | Description | -| ------------------- | ----------------------------- | -| `counter_key` | JSON 8 fields (Composite Key) | -| `reservation_token` | UUID v4 สำหรับ Reserve-Confirm | -| `idempotency_key` | Request Idempotency Key | -| `template_used` | Format Template ที่ใช้ | -| `retry_count` | จำนวนครั้งที่ retry | -| `lock_wait_ms` | เวลารอ Redis lock (ms) | -| `total_duration_ms` | เวลารวมทั้งหมด (ms) | -| `fallback_used` | NONE / DB_LOCK / RETRY | +| Field | Description | +| ------------------- | ------------------------------ | +| `counter_key` | JSON 8 fields (Composite Key) | +| `reservation_token` | UUID v4 สำหรับ Reserve-Confirm | +| `idempotency_key` | Request Idempotency Key | +| `template_used` | Format Template ที่ใช้ | +| `retry_count` | จำนวนครั้งที่ retry | +| `lock_wait_ms` | เวลารอ Redis lock (ms) | +| `total_duration_ms` | เวลารวมทั้งหมด (ms) | +| `fallback_used` | NONE / DB_LOCK / RETRY | ### Error Types @@ -383,6 +385,7 @@ sequenceDiagram ## 📝 Changelog ### v1.7.0 + - Changed `document_number_counters` PK from 5 to **8 columns** - Added `document_number_reservations` table for Two-Phase Commit - Added `reset_scope` field (replaces `current_year`) @@ -390,6 +393,7 @@ sequenceDiagram - Added `idempotency_key` support ### v1.5.1 + - Initial implementation - Basic format templating - Counter management diff --git a/specs/99-archives/docs/Docker compose all.yaml b/specs/99-archives/docs/Docker compose all.yaml index 7d0a73e..ffae866 100644 --- a/specs/99-archives/docs/Docker compose all.yaml +++ b/specs/99-archives/docs/Docker compose all.yaml @@ -8,12 +8,12 @@ volumes: backend_node_modules: # (ที่เพิ่มใหม่) - db_data: # 2.4. Database - npm_data: # 2.8. Reverse Proxy - npm_letsencrypt: # 2.8. SSL Certs - es_data: # 6.2. Elasticsearch - n8n_data: # 2.7. n8n - gitea_data: # 2.2. Gitea + db_data: # 2.4. Database + npm_data: # 2.8. Reverse Proxy + npm_letsencrypt: # 2.8. SSL Certs + es_data: # 6.2. Elasticsearch + n8n_data: # 2.7. n8n + gitea_data: # 2.2. Gitea # ========================================================== # Services (บริการทั้งหมดของระบบ) @@ -27,9 +27,9 @@ services: container_name: npm restart: unless-stopped ports: - - '80:80' # HTTP - - '443:443' # HTTPS - - '81:81' # Admin UI + - '80:80' # HTTP + - '443:443' # HTTPS + - '81:81' # Admin UI volumes: - npm_data:/data - npm_letsencrypt:/etc/letsencrypt @@ -44,7 +44,7 @@ services: container_name: mariadb restart: unless-stopped ports: - - "3306:3306" + - '3306:3306' volumes: - db_data:/var/lib/mysql environment: @@ -64,7 +64,7 @@ services: container_name: pma restart: unless-stopped ports: - - "8080:80" + - '8080:80' environment: - PMA_HOST=mariadb - PMA_PORT=3306 @@ -89,7 +89,7 @@ services: networks: - lcbp3 ports: - - "3000:3000" + - '3000:3000' environment: # --- Database Connection (จากไฟล์เดิม) --- - DB_HOST=mariadb @@ -123,7 +123,7 @@ services: restart: unless-stopped command: npm run dev ports: - - "3001:3000" # (ใช้ Host Port 3001) + - '3001:3000' # (ใช้ Host Port 3001) networks: - lcbp3 volumes: @@ -143,7 +143,7 @@ services: container_name: elasticsearch restart: unless-stopped ports: - - "9200:9200" + - '9200:9200' volumes: - es_data:/usr/share/elasticsearch/data environment: @@ -161,7 +161,7 @@ services: container_name: n8n restart: unless-stopped ports: - - "5678:5678" + - '5678:5678' volumes: - n8n_data:/home/node/.n8n environment: @@ -177,8 +177,8 @@ services: container_name: gitea restart: unless-stopped ports: - - "3002:3000" # (ใช้ Host Port 3002) - - "2222:22" # (ใช้ Host Port 2222 สำหรับ SSH) + - '3002:3000' # (ใช้ Host Port 3002) + - '2222:22' # (ใช้ Host Port 2222 สำหรับ SSH) volumes: - gitea_data:/data networks: @@ -191,4 +191,4 @@ services: # ========================================================== networks: lcbp3: - external: true \ No newline at end of file + external: true diff --git a/specs/99-archives/docs/Markdown/0_Requirements_V1_4_3.md b/specs/99-archives/docs/Markdown/0_Requirements_V1_4_3.md index 4ea2945..1792b34 100644 --- a/specs/99-archives/docs/Markdown/0_Requirements_V1_4_3.md +++ b/specs/99-archives/docs/Markdown/0_Requirements_V1_4_3.md @@ -306,6 +306,7 @@ - ต้อง encrypt sensitive data ใน JSON fields ### **3.12 ข้อกำหนดพิเศษ** + - **ผู้ใช้งานที่มีสิทธิ์ระดับสูง (Global) หรือผู้ได้รับอนุญาตเป็นกรณีพิเศษ** - สามารถเลือก **สร้างในนามองค์กร (Create on behalf of)** ได้ เพื่อให้สามารถออกเลขที่เอกสาร (Running Number) ขององค์กรอื่นได้โดยไม่ต้องล็อกอินใหม่ - สามารถทำงานแทนผู้ใช้งานอื่นได้ Routing & Workflow ของ Correspondence, RFA, Circulation Sheet @@ -327,15 +328,15 @@ ### **4.3. การกำหนดบทบาท (Roles) และขอบเขต (Scope)** -| บทบาท (Role) | ขอบเขต (Scope) | คำอธิบาย | สิทธิ์หลัก (Key Permissions) | -| :------------------- | :------------- | :------------------ | :----------------------------------------------------------------------------- | -| **Superadmin** | Global | ผู้ดูแลระบบสูงสุด | ทำทุกอย่างในระบบ, จัดการองค์กร, จัดการข้อมูลหลักระดับ Global | -| **Org Admin** | Organization | ผู้ดูแลองค์กร | จัดการผู้ใช้ในองค์กร, จัดการบทบาท/สิทธิ์ภายในองค์กร, ดูรายงานขององค์กร | -| **Document Control** | Organization | ควบคุมเอกสารขององค์กร | เพิ่ม/แก้ไข/ลบเอกสาร, กำหนดสิทธิ์เอกสารภายในองค์กร | -| **Editor** | Organization | ผู้แก้ไขเอกสารขององค์กร | เพิ่ม/แก้ไขเอกสารที่ได้รับมอบหมาย | -| **Viewer** | Organization | ผู้ดูเอกสารขององค์กร | ดูเอกสารที่มีสิทธิ์เข้าถึง | -| **Project Manager** | Project | ผู้จัดการโครงการ | จัดการสมาชิกในโครงการ (เพิ่ม/ลบ/มอบบทบาท), สร้าง/จัดการสัญญาในโครงการ, ดูรายงานโครงการ | -| **Contract Admin** | Contract | ผู้ดูแลสัญญา | จัดการสมาชิกในสัญญา, สร้าง/จัดการข้อมูลหลักเฉพาะสัญญา (ถ้ามี), อนุมัติเอกสารในสัญญา | +| บทบาท (Role) | ขอบเขต (Scope) | คำอธิบาย | สิทธิ์หลัก (Key Permissions) | +| :------------------- | :------------- | :---------------------- | :------------------------------------------------------------------------------------- | +| **Superadmin** | Global | ผู้ดูแลระบบสูงสุด | ทำทุกอย่างในระบบ, จัดการองค์กร, จัดการข้อมูลหลักระดับ Global | +| **Org Admin** | Organization | ผู้ดูแลองค์กร | จัดการผู้ใช้ในองค์กร, จัดการบทบาท/สิทธิ์ภายในองค์กร, ดูรายงานขององค์กร | +| **Document Control** | Organization | ควบคุมเอกสารขององค์กร | เพิ่ม/แก้ไข/ลบเอกสาร, กำหนดสิทธิ์เอกสารภายในองค์กร | +| **Editor** | Organization | ผู้แก้ไขเอกสารขององค์กร | เพิ่ม/แก้ไขเอกสารที่ได้รับมอบหมาย | +| **Viewer** | Organization | ผู้ดูเอกสารขององค์กร | ดูเอกสารที่มีสิทธิ์เข้าถึง | +| **Project Manager** | Project | ผู้จัดการโครงการ | จัดการสมาชิกในโครงการ (เพิ่ม/ลบ/มอบบทบาท), สร้าง/จัดการสัญญาในโครงการ, ดูรายงานโครงการ | +| **Contract Admin** | Contract | ผู้ดูแลสัญญา | จัดการสมาชิกในสัญญา, สร้าง/จัดการข้อมูลหลักเฉพาะสัญญา (ถ้ามี), อนุมัติเอกสารในสัญญา | ### **4.4. Token Management (ปรับปรุง)** @@ -363,14 +364,14 @@ ### **4.6. การจัดการข้อมูลหลัก (Master Data Management) ที่แบ่งตามระดับ** -| ข้อมูลหลัก | ผู้มีสิทธิ์จัดการ | ระดับ | -| :---------------------------------- | :------------------------------ | :------------------------------ | -| ประเภทเอกสาร (Correspondence, RFA) | **Superadmin** | Global | -| สถานะเอกสาร (Draft, Approved, etc.) | **Superadmin** | Global | -| หมวดหมู่แบบ (Shop Drawing) | **Project Manager** | Project (สร้างใหม่ได้ภายในโครงการ) | -| Tags | **Org Admin / Project Manager** | Organization / Project | -| บทบาทและสิทธิ์ (Custom Roles) | **Superadmin / Org Admin** | Global / Organization | -| Document Numbering Formats | **Superadmin / Admin** | Global / Organization | +| ข้อมูลหลัก | ผู้มีสิทธิ์จัดการ | ระดับ | +| :---------------------------------- | :------------------------------ | :--------------------------------- | +| ประเภทเอกสาร (Correspondence, RFA) | **Superadmin** | Global | +| สถานะเอกสาร (Draft, Approved, etc.) | **Superadmin** | Global | +| หมวดหมู่แบบ (Shop Drawing) | **Project Manager** | Project (สร้างใหม่ได้ภายในโครงการ) | +| Tags | **Org Admin / Project Manager** | Organization / Project | +| บทบาทและสิทธิ์ (Custom Roles) | **Superadmin / Org Admin** | Global / Organization | +| Document Numbering Formats | **Superadmin / Admin** | Global / Organization | ## **👥 5. ข้อกำหนดด้านผู้ใช้งาน (User Interface & Experience)** diff --git a/specs/99-archives/docs/Markdown/0_Requirements_V1_4_4.md b/specs/99-archives/docs/Markdown/0_Requirements_V1_4_4.md index 833cc37..e92a999 100644 --- a/specs/99-archives/docs/Markdown/0_Requirements_V1_4_4.md +++ b/specs/99-archives/docs/Markdown/0_Requirements_V1_4_4.md @@ -50,14 +50,12 @@ ### **2.3 Core Services:** - **Code Hosting:** Gitea (Self-hosted on QNAP) - - Application name: git - Service name: gitea - Domain: `git.np-dms.work` - หน้าที่: เป็นศูนย์กลางในการเก็บและจัดการเวอร์ชันของโค้ด (Source Code) สำหรับทุกส่วน - **Backend / Data Platform:** NestJS - - Application name: lcbp3-backend - Service name: backend - Domain: `backend.np-dms.work` @@ -65,7 +63,6 @@ - หน้าที่: จัดการโครงสร้างข้อมูล (Data Models), สร้าง API, จัดการสิทธิ์ผู้ใช้ (Roles & Permissions), และสร้าง Workflow ทั้งหมดของระบบ - **Database:** MariaDB 10.11 - - Application name: lcbp3-db - Service name: mariadb - Domain: `db.np-dms.work` @@ -73,7 +70,6 @@ - Tooling: DBeaver (Community Edition), phpmyadmin สำหรับการออกแบบและจัดการฐานข้อมูล - **Database Management:** phpMyAdmin - - Application name: lcbp3-db - Service: phpmyadmin:5-apache - Service name: pma @@ -81,7 +77,6 @@ - หน้าที่: จัดการฐานข้อมูล mariadb ผ่าน Web UI - **Frontend:** Next.js - - Application name: lcbp3-frontend - Service name: frontend - Domain: `lcbp3.np-dms.work` @@ -91,7 +86,6 @@ - หน้าที่: สร้างหน้าตาเว็บแอปพลิเคชันสำหรับให้ผู้ใช้งานเข้ามาดู Dashboard, จัดการเอกสาร, และติดตามงาน โดยจะสื่อสารกับ Backend ผ่าน API - **Workflow Automation:** n8n - - Application name: lcbp3-n8n - Service: n8nio/n8n:latest - Service name: n8n @@ -99,7 +93,6 @@ - หน้าที่: จัดการ workflow ระหว่าง Backend และ Line - **Reverse Proxy:** Nginx Proxy Manager - - Application name: lcbp3-npm - Service: Nginx Proxy Manager (nginx-proxy-manage: latest) - Service name: npm @@ -200,18 +193,15 @@ ### **3.5. การจัดการ Workflow (Unified Workflow)** - 3.5.1 Workflow Definition: - - Admin ต้องสามารถสร้าง/แก้ไข Workflow Rule ได้ผ่านหน้าจอ UI (DSL Editor) ร - องรับการกำหนด State, Transition, Required Role, Condition (JS Expression) - 3.5.2 Workflow Execution: - - ระบบต้องรองรับการสร้าง Instance ของ Workflow ผูกกับเอกสาร (Polymorphic) - รองรับการเปลี่ยนสถานะ (Action) เช่น Approve, Reject, Comment, Return - Auto-Action: รองรับการเปลี่ยนสถานะอัตโนมัติเมื่อครบเงื่อนไข (เช่น Review ครบทุกคน) - 3.5.3 Flexibility: - - รองรับ Parallel Review (ส่งให้หลายคนตรวจพร้อมกัน) - รองรับ Conditional Flow (เช่น ถ้ายอดเงิน > X ให้เพิ่มผู้อนุมัติ) @@ -248,13 +238,11 @@ ### **3.9. การจัดเก็บไฟล์ (File Handling - ปรับปรุงใหญ่)** - **3.9.1 Two-Phase Storage Strategy:** - 1. **Phase 1 (Upload):** ไฟล์ถูกอัปโหลดเข้าโฟลเดอร์ `temp/` และได้รับ `temp_id` 2. **Phase 2 (Commit):** เมื่อ User กด Submit ฟอร์มสำเร็จ ระบบจะย้ายไฟล์จาก `temp/` ไปยัง `permanent/{YYYY}/{MM}/` และบันทึกลง Database ภายใน Transaction เดียวกัน 3. **Cleanup:** มี Cron Job ลบไฟล์ใน `temp/` ที่ค้างเกิน 24 ชม. (Orphan Files) - **3.9.2 Security:** - - Virus Scan (ClamAV) ก่อนย้ายเข้า Permanent - Whitelist File Types: PDF, DWG, DOCX, XLSX, ZIP - Max Size: 50MB @@ -284,14 +272,12 @@ ### **3.11 การจัดการ JSON Details (JSON & Performance - ปรับปรุง)** - **3.11.1 วัตถุประสงค์** - - จัดเก็บข้อมูลแบบไดนามิกที่เฉพาะเจาะจงกับแต่ละประเภทของเอกสาร - รองรับการขยายตัวของระบบโดยไม่ต้องเปลี่ยนแปลง database schema - จัดการ metadata และข้อมูลประกอบสำหรับ correspondence, routing, และ workflows - **3.11.2 โครงสร้าง JSON Schema** ระบบต้องมี predefined JSON schemas สำหรับประเภทเอกสารต่างๆ: - - **3.11.2.1 Correspondence Types** - **GENERIC**: ข้อมูลพื้นฐานสำหรับเอกสารทั่วไป - **RFI**: รายละเอียดคำถามและข้อมูลทางเทคนิค @@ -310,14 +296,12 @@ - **3.11.3 Virtual Columns (ใหม่):** สำหรับ Field ใน JSON ที่ต้องใช้ในการค้นหา (Search) หรือจัดเรียง (Sort) บ่อยๆ **ต้องสร้าง Generated Column (Virtual Column)** ใน Database และทำ Index ไว้ เพื่อประสิทธิภาพสูงสุด - **3.11.4 Validation Rules** - - ต้องมี JSON schema validation สำหรับแต่ละประเภท - ต้องรองรับ versioning ของ schema - ต้องมี default values สำหรับ field ที่ไม่บังคับ - ต้องตรวจสอบ data types และ format ให้ถูกต้อง - **3.11.5 Performance Requirements** - - JSON field ต้องมีขนาดไม่เกิน 50KB - ต้องรองรับ indexing สำหรับ field ที่ใช้ค้นหาบ่อย - ต้องมี compression สำหรับ JSON ขนาดใหญ่ @@ -454,7 +438,6 @@ ### **6.1. การบันทึกการกระทำ (Audit Log):** ทุกการกระทำที่สำคัญของผู้ใช้ (สร้าง, แก้ไข, ลบ, ส่ง) จะถูกบันทึกไว้ใน audit_logs เพื่อการตรวจสอบย้อนหลัง - **6.1.1 ขอบเขตการบันทึก Audit Log:** - - ทุกการสร้าง/แก้ไข/ลบ ข้อมูลสำคัญ (correspondences, RFAs, drawings, users, permissions) - ทุกการเข้าถึงข้อมูล sensitive (user data, financial information) - ทุกการเปลี่ยนสถานะ workflow (status transitions) @@ -486,7 +469,6 @@ ### **6.5. ประสิทธิภาพ (Performance):** มีการใช้ Caching กับข้อมูลที่เรียกใช้บ่อย และใช้ Pagination ในตารางข้อมูลเพื่อจัดการข้อมูลจำนวนมาก - **6.5.1 ตัวชี้วัดประสิทธิภาพ:** - - **API Response Time:** < 200ms (90th percentile) สำหรับ operation ทั่วไป - **Search Query Performance:** < 500ms สำหรับการค้นหาขั้นสูง - **File Upload Performance:** < 30 seconds สำหรับไฟล์ขนาด 50MB @@ -514,7 +496,6 @@ - การจัดการ Secret (เช่น รหัสผ่าน DB, JWT Secret) จะต้องทำผ่าน Environment Variable ของ Docker เพื่อความปลอดภัยสูงสุด - **6.6.1 Rate Limiting Strategy:** - - **Anonymous Endpoints:** 100 requests/hour ต่อ IP address - **Authenticated Endpoints:** - Viewer: 500 requests/hour @@ -528,14 +509,12 @@ - ต้องบันทึก log เมื่อมีการ trigger rate limiting - **6.6.2 Error Handling และ Resilience:** - - ต้องมี circuit breaker pattern สำหรับ external service calls - ต้องมี retry mechanism ด้วย exponential backoff - ต้องมี graceful degradation เมื่อบริการภายนอกล้มเหลว - Error messages ต้องไม่เปิดเผยข้อมูล sensitive - **6.6.3 Input Validation:** - - ต้องมี input validation ทั้งฝั่ง client และ server (defense in depth) - ต้องป้องกัน OWASP Top 10 vulnerabilities: - SQL Injection (ใช้ parameterized queries ผ่าน ORM) @@ -589,7 +568,6 @@ ### **6.8. กลยุทธ์การแจ้งเตือน (Notification Strategy - ปรับปรุง):** - **6.8.1 ระบบจะส่งการแจ้งเตือน (ผ่าน Email หรือ Line [cite: 2.7]) เมื่อมีการกระทำที่สำคัญ** ดังนี้: - 1. เมื่อมีเอกสารใหม่ (Correspondence, RFA) ถูกส่งมาถึงองค์กรณ์ของเรา 2. เมื่อมีใบเวียน (Circulation) ใหม่ มอบหมายงานมาที่เรา 3. (ทางเลือก) เมื่อเอกสารที่เราส่งไป ถูกดำเนินการ (เช่น อนุมัติ/ปฏิเสธ) diff --git a/specs/99-archives/docs/Markdown/1_FullStackJS_V1_4_3.md b/specs/99-archives/docs/Markdown/1_FullStackJS_V1_4_3.md index 60f6fda..c97db1e 100644 --- a/specs/99-archives/docs/Markdown/1_FullStackJS_V1_4_3.md +++ b/specs/99-archives/docs/Markdown/1_FullStackJS_V1_4_3.md @@ -9,100 +9,101 @@ แนวทางปฏิบัติที่ดีที่สุดแบบครบวงจรสำหรับการพัฒนา NestJS Backend, NextJS Frontend และ Tailwind-based UI/UX ในสภาพแวดล้อม TypeScript มุ่งเน้นที่ **"Data Integrity First"** (ความถูกต้องของข้อมูลต้องมาก่อน) ตามด้วย Security และ UX -* **ความชัดเจน (clarity), ความง่ายในการบำรุงรักษา (maintainability), ความสอดคล้องกัน (consistency) และ การเข้าถึงได้ (accessibility)** ตลอดทั้งสแต็ก -* **Strict Typing:** ใช้ TypeScript อย่างเคร่งครัด ห้าม `any` -* **Consistency:** ใช้ภาษาอังกฤษใน Code / ภาษาไทยใน Comment -* **Resilience:** ระบบต้องทนทานต่อ Network Failure และ Race Condition +- **ความชัดเจน (clarity), ความง่ายในการบำรุงรักษา (maintainability), ความสอดคล้องกัน (consistency) และ การเข้าถึงได้ (accessibility)** ตลอดทั้งสแต็ก +- **Strict Typing:** ใช้ TypeScript อย่างเคร่งครัด ห้าม `any` +- **Consistency:** ใช้ภาษาอังกฤษใน Code / ภาษาไทยใน Comment +- **Resilience:** ระบบต้องทนทานต่อ Network Failure และ Race Condition ## ⚙️ **2. แนวทางทั่วไปสำหรับ TypeScript** ### **2.1 หลักการพื้นฐาน** -* ใช้ **ภาษาอังกฤษ** สำหรับโค้ด -* ใช้ **ภาษาไทย** สำหรับ comment และเอกสารทั้งหมด -* กำหนดไทป์ (type) อย่างชัดเจนสำหรับตัวแปร, พารามิเตอร์ และค่าที่ส่งกลับ (return values) ทั้งหมด -* หลีกเลี่ยงการใช้ any; ให้สร้างไทป์ (types) หรืออินเทอร์เฟซ (interfaces) ที่กำหนดเอง -* ใช้ **JSDoc** สำหรับคลาส (classes) และเมธอด (methods) ที่เป็น public -* ส่งออก (Export) **สัญลักษณ์หลัก (main symbol) เพียงหนึ่งเดียว** ต่อไฟล์ -* หลีกเลี่ยงบรรทัดว่างภายในฟังก์ชัน -* ระบุ // File: path/filename ในบรรทัดแรกของทุกไฟล์ -* ระบุ // บันทึกการแก้ไข, หากมีการแก้ไขเพิ่มในอนาคต ให้เพิ่มบันทึก +- ใช้ **ภาษาอังกฤษ** สำหรับโค้ด +- ใช้ **ภาษาไทย** สำหรับ comment และเอกสารทั้งหมด +- กำหนดไทป์ (type) อย่างชัดเจนสำหรับตัวแปร, พารามิเตอร์ และค่าที่ส่งกลับ (return values) ทั้งหมด +- หลีกเลี่ยงการใช้ any; ให้สร้างไทป์ (types) หรืออินเทอร์เฟซ (interfaces) ที่กำหนดเอง +- ใช้ **JSDoc** สำหรับคลาส (classes) และเมธอด (methods) ที่เป็น public +- ส่งออก (Export) **สัญลักษณ์หลัก (main symbol) เพียงหนึ่งเดียว** ต่อไฟล์ +- หลีกเลี่ยงบรรทัดว่างภายในฟังก์ชัน +- ระบุ // File: path/filename ในบรรทัดแรกของทุกไฟล์ +- ระบุ // บันทึกการแก้ไข, หากมีการแก้ไขเพิ่มในอนาคต ให้เพิ่มบันทึก ### **2.2 Configuration & Secrets Management** -* **Production/Staging:** ห้ามใส่ Secrets (Password, Keys) ใน `docker-compose.yml` หลัก -* **Development:** ให้สร้างไฟล์ `docker-compose.override.yml` (เพิ่มใน `.gitignore`) เพื่อ Inject ตัวแปร Environment ที่เป็นความลับ -* **Validation:** ใช้ `joi` หรือ `zod` ในการ Validate Environment Variables ตอน Start App หากขาดตัวแปรสำคัญให้ Throw Error ทันที +- **Production/Staging:** ห้ามใส่ Secrets (Password, Keys) ใน `docker-compose.yml` หลัก +- **Development:** ให้สร้างไฟล์ `docker-compose.override.yml` (เพิ่มใน `.gitignore`) เพื่อ Inject ตัวแปร Environment ที่เป็นความลับ +- **Validation:** ใช้ `joi` หรือ `zod` ในการ Validate Environment Variables ตอน Start App หากขาดตัวแปรสำคัญให้ Throw Error ทันที ### **2.3 Idempotency (ความสามารถในการทำซ้ำได้)** -* สำหรับการทำงานที่สำคัญ (Create Document, Approve, Transactional) **ต้อง** ออกแบบให้เป็น Idempotent -* Client **ต้อง** ส่ง Header `Idempotency-Key` (UUID) มากับ Request -* Server **ต้อง** ตรวจสอบว่า Key นี้เคยถูกประมวลผลสำเร็จไปแล้วหรือไม่ ถ้าใช่ ให้คืนค่าเดิมโดยไม่ทำซ้ำ +- สำหรับการทำงานที่สำคัญ (Create Document, Approve, Transactional) **ต้อง** ออกแบบให้เป็น Idempotent +- Client **ต้อง** ส่ง Header `Idempotency-Key` (UUID) มากับ Request +- Server **ต้อง** ตรวจสอบว่า Key นี้เคยถูกประมวลผลสำเร็จไปแล้วหรือไม่ ถ้าใช่ ให้คืนค่าเดิมโดยไม่ทำซ้ำ ### **2.4 ข้อตกลงในการตั้งชื่อ (Naming Conventions)** -| Entity (สิ่งที่ตั้งชื่อ) | Convention (รูปแบบ) | Example (ตัวอย่าง) | -| :-------------------- | :----------------- | :--------------------------------- | -| Classes | PascalCase | UserService | -| Property | snake_case | user_id | -| Variables & Functions | camelCase | getUserInfo | -| Files & Folders | kebab-case | user-service.ts | -| Environment Variables | UPPERCASE | DATABASE_URL | -| Booleans | Verb + Noun | isActive, canDelete, hasPermission | +| Entity (สิ่งที่ตั้งชื่อ) | Convention (รูปแบบ) | Example (ตัวอย่าง) | +| :----------------------- | :------------------ | :--------------------------------- | +| Classes | PascalCase | UserService | +| Property | snake_case | user_id | +| Variables & Functions | camelCase | getUserInfo | +| Files & Folders | kebab-case | user-service.ts | +| Environment Variables | UPPERCASE | DATABASE_URL | +| Booleans | Verb + Noun | isActive, canDelete, hasPermission | ใช้คำเต็ม — ไม่ใช้อักษรย่อ — ยกเว้นคำมาตรฐาน (เช่น API, URL, req, res, err, ctx) ### 🧩**2.5 ฟังก์ชัน (Functions)** -* เขียนฟังก์ชันให้สั้น และทำ **หน้าที่เพียงอย่างเดียว** (single-purpose) (\< 20 บรรทัด) -* ใช้ **early returns** เพื่อลดการซ้อน (nesting) ของโค้ด -* ใช้ **map**, **filter**, **reduce** แทนการใช้ loops เมื่อเหมาะสม -* ควรใช้ **arrow functions** สำหรับตรรกะสั้นๆ, และใช้ **named functions** ในกรณีอื่น -* ใช้ **default parameters** แทนการตรวจสอบค่า null -* จัดกลุ่มพารามิเตอร์หลายตัวให้เป็นอ็อบเจกต์เดียว (RO-RO pattern) -* ส่งค่ากลับ (Return) เป็นอ็อบเจกต์ที่มีไทป์กำหนด (typed objects) ไม่ใช่ค่าพื้นฐาน (primitives) -* รักษาระดับของสิ่งที่เป็นนามธรรม (abstraction level) ให้เป็นระดับเดียวในแต่ละฟังก์ชัน +- เขียนฟังก์ชันให้สั้น และทำ **หน้าที่เพียงอย่างเดียว** (single-purpose) (\< 20 บรรทัด) +- ใช้ **early returns** เพื่อลดการซ้อน (nesting) ของโค้ด +- ใช้ **map**, **filter**, **reduce** แทนการใช้ loops เมื่อเหมาะสม +- ควรใช้ **arrow functions** สำหรับตรรกะสั้นๆ, และใช้ **named functions** ในกรณีอื่น +- ใช้ **default parameters** แทนการตรวจสอบค่า null +- จัดกลุ่มพารามิเตอร์หลายตัวให้เป็นอ็อบเจกต์เดียว (RO-RO pattern) +- ส่งค่ากลับ (Return) เป็นอ็อบเจกต์ที่มีไทป์กำหนด (typed objects) ไม่ใช่ค่าพื้นฐาน (primitives) +- รักษาระดับของสิ่งที่เป็นนามธรรม (abstraction level) ให้เป็นระดับเดียวในแต่ละฟังก์ชัน ### 🧱**2.6 การจัดการข้อมูล (Data Handling)** -* ห่อหุ้มข้อมูล (Encapsulate) ในไทป์แบบผสม (composite types) -* ใช้ **immutability** (การไม่เปลี่ยนแปลงค่า) ด้วย readonly และ as const -* ทำการตรวจสอบความถูกต้องของข้อมูล (Validations) ในคลาสหรือ DTOs ไม่ใช่ภายในฟังก์ชันทางธุรกิจ -* ตรวจสอบความถูกต้องของข้อมูลโดยใช้ DTOs ที่มีไทป์กำหนดเสมอ +- ห่อหุ้มข้อมูล (Encapsulate) ในไทป์แบบผสม (composite types) +- ใช้ **immutability** (การไม่เปลี่ยนแปลงค่า) ด้วย readonly และ as const +- ทำการตรวจสอบความถูกต้องของข้อมูล (Validations) ในคลาสหรือ DTOs ไม่ใช่ภายในฟังก์ชันทางธุรกิจ +- ตรวจสอบความถูกต้องของข้อมูลโดยใช้ DTOs ที่มีไทป์กำหนดเสมอ ### 🧰**2.7 คลาส (Classes)** -* ปฏิบัติตามหลักการ **SOLID** -* ควรใช้ **composition มากกว่า inheritance** (Prefer composition over inheritance) -* กำหนด **interfaces** สำหรับสัญญา (contracts) -* ให้คลาสมุ่งเน้นการทำงานเฉพาะอย่างและมีขนาดเล็ก (\< 200 บรรทัด, \< 10 เมธอด, \< 10 properties) +- ปฏิบัติตามหลักการ **SOLID** +- ควรใช้ **composition มากกว่า inheritance** (Prefer composition over inheritance) +- กำหนด **interfaces** สำหรับสัญญา (contracts) +- ให้คลาสมุ่งเน้นการทำงานเฉพาะอย่างและมีขนาดเล็ก (\< 200 บรรทัด, \< 10 เมธอด, \< 10 properties) ### 🚨**2.8 การจัดการข้อผิดพลาด (Error Handling)** -* ใช้ Exceptions สำหรับข้อผิดพลาดที่ไม่คาดคิด -* ดักจับ (Catch) ข้อผิดพลาดเพื่อแก้ไขหรือเพิ่มบริบท (context) เท่านั้น; หากไม่เช่นนั้น ให้ใช้ global error handlers -* ระบุข้อความข้อผิดพลาด (error messages) ที่มีความหมายเสมอ +- ใช้ Exceptions สำหรับข้อผิดพลาดที่ไม่คาดคิด +- ดักจับ (Catch) ข้อผิดพลาดเพื่อแก้ไขหรือเพิ่มบริบท (context) เท่านั้น; หากไม่เช่นนั้น ให้ใช้ global error handlers +- ระบุข้อความข้อผิดพลาด (error messages) ที่มีความหมายเสมอ ### 🧪**2.9 การทดสอบ (ทั่วไป) (Testing (General))** -* ใช้รูปแบบ **Arrange–Act–Assert** -* ใช้ชื่อตัวแปรในการทดสอบที่สื่อความหมาย (inputData, expectedOutput) -* เขียน **unit tests** สำหรับ public methods ทั้งหมด -* จำลอง (Mock) การพึ่งพาภายนอก (external dependencies) -* เพิ่ม **acceptance tests** ต่อโมดูลโดยใช้รูปแบบ Given–When-Then +- ใช้รูปแบบ **Arrange–Act–Assert** +- ใช้ชื่อตัวแปรในการทดสอบที่สื่อความหมาย (inputData, expectedOutput) +- เขียน **unit tests** สำหรับ public methods ทั้งหมด +- จำลอง (Mock) การพึ่งพาภายนอก (external dependencies) +- เพิ่ม **acceptance tests** ต่อโมดูลโดยใช้รูปแบบ Given–When-Then ### **Testing Strategy โดยละเอียด** -* **Test Pyramid Structure** +- **Test Pyramid Structure** - /\ - / \ E2E Tests (10%) - /____\ Integration Tests (20%) - / \ Unit Tests (70%) -/________\ + /\ -* **Testing Tools Stack** + / \ E2E Tests (10%) + /\_**\_\ Integration Tests (20%) + / \ Unit Tests (70%) + /**\_\_****\ + +- **Testing Tools Stack** ```typescript // Backend Testing Stack @@ -111,7 +112,7 @@ const backendTesting = { integration: ['Supertest', 'Testcontainers', 'Jest'], e2e: ['Supertest', 'Jest', 'Database Seeds'], security: ['Jest', 'Custom Security Test Helpers'], - performance: ['Jest', 'autocannon', 'artillery'] + performance: ['Jest', 'autocannon', 'artillery'], }; // Frontend Testing Stack @@ -119,11 +120,11 @@ const frontendTesting = { unit: ['Vitest', 'React Testing Library'], integration: ['React Testing Library', 'MSW'], e2e: ['Playwright', 'Jest'], - visual: ['Playwright', 'Loki'] + visual: ['Playwright', 'Loki'], }; ``` -* **Test Data Management** +- **Test Data Management** ```typescript // Test Data Factories @@ -139,7 +140,7 @@ const testScenarios = { edgeCases: 'Boundary conditions and limits', errorConditions: 'Error handling and recovery', security: 'Authentication and authorization', - performance: 'Load and stress conditions' + performance: 'Load and stress conditions', }; ``` @@ -147,14 +148,14 @@ const testScenarios = { ### **3.1 หลักการ** -* **สถาปัตยกรรมแบบโมดูลาร์ (Modular architecture)**: - * หนึ่งโมดูลต่อหนึ่งโดเมน - * โครงสร้างแบบ Controller → Service → Repository (Model) -* API-First: มุ่งเน้นการสร้าง API ที่มีคุณภาพสูง มีเอกสารประกอบ (Swagger) ที่ชัดเจนสำหรับ Frontend Team -* DTOs ที่ตรวจสอบความถูกต้องด้วย **class-validator** -* ใช้ **MikroORM** (หรือ TypeORM/Prisma) สำหรับการคงอยู่ของข้อมูล (persistence) ซึ่งสอดคล้องกับสคีมา MariaDB -* ห่อหุ้มโค้ดที่ใช้ซ้ำได้ไว้ใน **common module** (@app/common): - * Configs, decorators, DTOs, guards, interceptors, notifications, shared services, types, validators +- **สถาปัตยกรรมแบบโมดูลาร์ (Modular architecture)**: + - หนึ่งโมดูลต่อหนึ่งโดเมน + - โครงสร้างแบบ Controller → Service → Repository (Model) +- API-First: มุ่งเน้นการสร้าง API ที่มีคุณภาพสูง มีเอกสารประกอบ (Swagger) ที่ชัดเจนสำหรับ Frontend Team +- DTOs ที่ตรวจสอบความถูกต้องด้วย **class-validator** +- ใช้ **MikroORM** (หรือ TypeORM/Prisma) สำหรับการคงอยู่ของข้อมูล (persistence) ซึ่งสอดคล้องกับสคีมา MariaDB +- ห่อหุ้มโค้ดที่ใช้ซ้ำได้ไว้ใน **common module** (@app/common): + - Configs, decorators, DTOs, guards, interceptors, notifications, shared services, types, validators ### **3.2 Database & Data Modeling (MariaDB + TypeORM)** @@ -194,13 +195,13 @@ CREATE INDEX idx_ref_project_id ON correspondence_revisions(ref_project_id); ปรับปรุง Service จัดการไฟล์ให้รองรับ Transactional Integrity 1. **Upload (Phase 1):** - * รับไฟล์ → Scan Virus (ClamAV) → Save ลงโฟลเดอร์ `temp/` - * Return `temp_id` และ Metadata กลับไปให้ Client + - รับไฟล์ → Scan Virus (ClamAV) → Save ลงโฟลเดอร์ `temp/` + - Return `temp_id` และ Metadata กลับไปให้ Client 2. **Commit (Phase 2):** - * เมื่อ Business Logic (เช่น Create Correspondence) ทำงานสำเร็จ - * Service จะย้ายไฟล์จาก `temp/` ไปยัง `permanent/{YYYY}/{MM}/` - * Update path ใน Database - * ทั้งหมดนี้ต้องอยู่ภายใต้ Database Transaction เดียวกัน (ถ้า DB Fail, ไฟล์จะค้างที่ Temp และถูกลบโดย Cron Job) + - เมื่อ Business Logic (เช่น Create Correspondence) ทำงานสำเร็จ + - Service จะย้ายไฟล์จาก `temp/` ไปยัง `permanent/{YYYY}/{MM}/` + - Update path ใน Database + - ทั้งหมดนี้ต้องอยู่ภายใต้ Database Transaction เดียวกัน (ถ้า DB Fail, ไฟล์จะค้างที่ Temp และถูกลบโดย Cron Job) ### **3.4 Document Numbering (Double-Lock Mechanism)** @@ -213,28 +214,28 @@ CREATE INDEX idx_ref_project_id ON correspondence_revisions(ref_project_id); ห้ามแยก Logic ระหว่าง `CorrespondenceRouting` และ `RfaWorkflow` ออกจากกันเด็ดขาด ให้สร้าง `WorkflowEngineService` ที่เป็น Generic: -* **Input:** `currentState`, `action`, `rules (Guard)` -* **Output:** `nextState`, `assignee` -* รองรับทั้ง Linear Flow (Routing) และ Complex Flow (RFA) ผ่าน Configuration +- **Input:** `currentState`, `action`, `rules (Guard)` +- **Output:** `nextState`, `assignee` +- รองรับทั้ง Linear Flow (Routing) และ Complex Flow (RFA) ผ่าน Configuration ### **3.6 ฟังก์ชันหลัก (Core Functionalities)** -* Global **filters** สำหรับการจัดการ exception -* **Middlewares** สำหรับการจัดการ request -* **Guards** สำหรับการอนุญาต (permissions) และ RBAC -* **Interceptors** สำหรับการแปลงข้อมูล response และการบันทึก log +- Global **filters** สำหรับการจัดการ exception +- **Middlewares** สำหรับการจัดการ request +- **Guards** สำหรับการอนุญาต (permissions) และ RBAC +- **Interceptors** สำหรับการแปลงข้อมูล response และการบันทึก log ### **3.7 ข้อจำกัดในการ Deploy (QNAP Container Station)** -* **ห้ามใช้ไฟล์ .env** ในการตั้งค่า Environment Variables [cite: 2.1] -* การตั้งค่าทั้งหมด (เช่น Database connection string, JWT secret) **จะต้องถูกกำหนดผ่าน Environment Variable ใน docker-compose.yml โดยตรง** [cite: 6.5] ซึ่งจะจัดการผ่าน UI ของ QNAP Container Station [cite: 2.1] +- **ห้ามใช้ไฟล์ .env** ในการตั้งค่า Environment Variables [cite: 2.1] +- การตั้งค่าทั้งหมด (เช่น Database connection string, JWT secret) **จะต้องถูกกำหนดผ่าน Environment Variable ใน docker-compose.yml โดยตรง** [cite: 6.5] ซึ่งจะจัดการผ่าน UI ของ QNAP Container Station [cite: 2.1] ### **3.8 ข้อจำกัดด้านความปลอดภัย (Security Constraints):** -* **File Upload Security:** ต้องมี virus scanning (ClamAV), file type validation (white-list), และ file size limits (50MB) -* **Input Validation:** ต้องป้องกัน OWASP Top 10 vulnerabilities (SQL Injection, XSS, CSRF) -* **Rate Limiting:** ต้อง implement rate limiting ตาม strategy ที่กำหนด -* **Secrets Management:** ต้องมี mechanism สำหรับจัดการ sensitive secrets อย่างปลอดภัย แม้จะใช้ docker-compose.yml +- **File Upload Security:** ต้องมี virus scanning (ClamAV), file type validation (white-list), และ file size limits (50MB) +- **Input Validation:** ต้องป้องกัน OWASP Top 10 vulnerabilities (SQL Injection, XSS, CSRF) +- **Rate Limiting:** ต้อง implement rate limiting ตาม strategy ที่กำหนด +- **Secrets Management:** ต้องมี mechanism สำหรับจัดการ sensitive secrets อย่างปลอดภัย แม้จะใช้ docker-compose.yml ### **3.9 โครงสร้างโมดูลตามโดเมน (Domain-Driven Module Structure)** @@ -242,121 +243,121 @@ CREATE INDEX idx_ref_project_id ON correspondence_revisions(ref_project_id); #### 3.9.1 **CommonModule:** -* เก็บ Services ที่ใช้ร่วมกัน เช่น DatabaseModule, FileStorageService (จัดการไฟล์ใน QNAP), AuditLogService, NotificationService -* จัดการ audit_logs -* NotificationService ต้องรองรับ Triggers ที่ระบุใน Requirement 6.7 [cite: 6.7] +- เก็บ Services ที่ใช้ร่วมกัน เช่น DatabaseModule, FileStorageService (จัดการไฟล์ใน QNAP), AuditLogService, NotificationService +- จัดการ audit_logs +- NotificationService ต้องรองรับ Triggers ที่ระบุใน Requirement 6.7 [cite: 6.7] #### 3.9.2 **AuthModule:** -* จัดการะการยืนยันตัวตน (JWT, Guards) -* **(สำคัญ)** ต้องรับผิดชอบการตรวจสอบสิทธิ์ **4 ระดับ** [cite: 4.2]: สิทธิ์ระดับระบบ (Global Role), สิทธิ์ระดับองกรณ์ (Organization Role), สิทธิ์ระดับโปรเจกต์ (Project Role), และ สิทธิ์ระดับสัญญา (Contract Role) -* **(สำคัญ)** ต้องมี API สำหรับ **Admin Panel** เพื่อ: - * สร้างและจัดการ Role และการจับคู่ Permission แบบไดนามิก [cite: 4.3] - * ให้ Superadmin สร้าง Organizations และกำหนด Org Admin ได้ [cite: 4.6] - * ให้ Superadmin/Admin จัดการ document_number_formats (รูปแบบเลขที่เอกสาร), document_number_counters (Running Number) [cite: 3.10] +- จัดการะการยืนยันตัวตน (JWT, Guards) +- **(สำคัญ)** ต้องรับผิดชอบการตรวจสอบสิทธิ์ **4 ระดับ** [cite: 4.2]: สิทธิ์ระดับระบบ (Global Role), สิทธิ์ระดับองกรณ์ (Organization Role), สิทธิ์ระดับโปรเจกต์ (Project Role), และ สิทธิ์ระดับสัญญา (Contract Role) +- **(สำคัญ)** ต้องมี API สำหรับ **Admin Panel** เพื่อ: + - สร้างและจัดการ Role และการจับคู่ Permission แบบไดนามิก [cite: 4.3] + - ให้ Superadmin สร้าง Organizations และกำหนด Org Admin ได้ [cite: 4.6] + - ให้ Superadmin/Admin จัดการ document_number_formats (รูปแบบเลขที่เอกสาร), document_number_counters (Running Number) [cite: 3.10] #### 3.9.3 **UserModule:** -* จัดการ users, roles, permissions, global_default_roles, role_permissions, user_roles, user_project_roles -* **(สำคัญ)** ต้องมี API สำหรับ **Admin Panel** เพื่อ: - * สร้างและจัดการ Role และการจับคู่ Permission แบบไดนามิก [cite: 4.3] +- จัดการ users, roles, permissions, global_default_roles, role_permissions, user_roles, user_project_roles +- **(สำคัญ)** ต้องมี API สำหรับ **Admin Panel** เพื่อ: + - สร้างและจัดการ Role และการจับคู่ Permission แบบไดนามิก [cite: 4.3] #### 3.9.4 **ProjectModule:** -* จัดการ projects, organizations, contracts, project_parties, contract_parties +- จัดการ projects, organizations, contracts, project_parties, contract_parties #### 3.9.5 **MasterModule:** -* จัดการ master data (correspondence_types, rfa_types, rfa_status_codes, rfa_approve_codes, circulation_status_codes, correspondence_types, correspondence_status, tags) [cite: 4.5] +- จัดการ master data (correspondence_types, rfa_types, rfa_status_codes, rfa_approve_codes, circulation_status_codes, correspondence_types, correspondence_status, tags) [cite: 4.5] #### 3.9.6 **CorrespondenceModule (โมดูลศูนย์กลาง):** -* จัดการ correspondences, correspondence_revisions, correspondence_tags -* **(สำคัญ)** Service นี้ต้อง Inject DocumentNumberingService เพื่อขอเลขที่เอกสารใหม่ก่อนการสร้าง -* **(สำคัญ)** ตรรกะการสร้าง/อัปเดต Revision จะอยู่ใน Service นี้ -* จัดการ correspondence_attachments (ตารางเชื่อมไฟล์แนบ) -* รับผิดชอบ Routing **Correspondence Routing** (correspondence_routings, correspondence_routing_template_steps, correspondence_routing_templates, correspondence_status_transitions) สำหรับการส่งต่อเอกสารทั่วไประหว่างองค์กร +- จัดการ correspondences, correspondence_revisions, correspondence_tags +- **(สำคัญ)** Service นี้ต้อง Inject DocumentNumberingService เพื่อขอเลขที่เอกสารใหม่ก่อนการสร้าง +- **(สำคัญ)** ตรรกะการสร้าง/อัปเดต Revision จะอยู่ใน Service นี้ +- จัดการ correspondence_attachments (ตารางเชื่อมไฟล์แนบ) +- รับผิดชอบ Routing **Correspondence Routing** (correspondence_routings, correspondence_routing_template_steps, correspondence_routing_templates, correspondence_status_transitions) สำหรับการส่งต่อเอกสารทั่วไประหว่างองค์กร #### 3.9.7 **RfaModule:** -* จัดการ rfas, rfa_revisions, rfa_items -* รับผิดชอบเวิร์กโฟลว์ **"RFA Workflows"** (rfa_workflows, rfa_workflow_templates, rfa_workflow_template_steps, rfa_status_transitions) สำหรับการอนุมัติเอกสารทางเทคนิค +- จัดการ rfas, rfa_revisions, rfa_items +- รับผิดชอบเวิร์กโฟลว์ **"RFA Workflows"** (rfa_workflows, rfa_workflow_templates, rfa_workflow_template_steps, rfa_status_transitions) สำหรับการอนุมัติเอกสารทางเทคนิค #### 3.9.8 **DrawingModule:** -* จัดการ shop_drawings, shop_drawing_revisions, contract_drawings, contract_drawing_volumes, contract_drawing_cats, contract_drawing_sub_cats, shop_drawing_main_categories, shop_drawing_sub_categories, contract_drawing_subcat_cat_maps, shop_drawing_revision_contract_refs -* จัดการ shop_drawing_revision_attachments และ contract_drawing_attachments(ตารางเชื่อมไฟล์แนบ) +- จัดการ shop_drawings, shop_drawing_revisions, contract_drawings, contract_drawing_volumes, contract_drawing_cats, contract_drawing_sub_cats, shop_drawing_main_categories, shop_drawing_sub_categories, contract_drawing_subcat_cat_maps, shop_drawing_revision_contract_refs +- จัดการ shop_drawing_revision_attachments และ contract_drawing_attachments(ตารางเชื่อมไฟล์แนบ) #### 3.9.9 **CirculationModule:** -* จัดการ circulations, circulation_templates, circulation_assignees -* จัดการ circulation_attachments (ตารางเชื่อมไฟล์แนบ) -* รับผิดชอบเวิร์กโฟลว์ **"Circulations"** (circulation_status_transitions, circulation_template_assignees, circulation_assignees, circulation_recipients, circulation_actions, circulation_action_documents)สำหรับการเวียนเอกสาร **ภายในองค์กร** +- จัดการ circulations, circulation_templates, circulation_assignees +- จัดการ circulation_attachments (ตารางเชื่อมไฟล์แนบ) +- รับผิดชอบเวิร์กโฟลว์ **"Circulations"** (circulation_status_transitions, circulation_template_assignees, circulation_assignees, circulation_recipients, circulation_actions, circulation_action_documents)สำหรับการเวียนเอกสาร **ภายในองค์กร** #### 3.9.10 **TransmittalModule:** -* จัดการ transmittals และ transmittal_items +- จัดการ transmittals และ transmittal_items #### 3.9.11 **SearchModule:** -* ให้บริการค้นหาขั้นสูง (Advanced Search) [cite: 6.2] โดยใช้ **Elasticsearch** เพื่อรองรับการค้นหาแบบ Full-text จากชื่อเรื่อง, รายละเอียด, เลขที่เอกสาร, ประเภท, วันที่, และ Tags -* ระบบจะใช้ Elasticsearch Engine ในการจัดทำดัชนีเพื่อการค้นหาข้อมูลเชิงลึกจากเนื้อหาของเอกสาร โดยข้อมูลจะถูกส่งไปทำดัชนีจาก Backend (NestJS) ทุกครั้งที่มีการสร้างหรือแก้ไขเอกสาร +- ให้บริการค้นหาขั้นสูง (Advanced Search) [cite: 6.2] โดยใช้ **Elasticsearch** เพื่อรองรับการค้นหาแบบ Full-text จากชื่อเรื่อง, รายละเอียด, เลขที่เอกสาร, ประเภท, วันที่, และ Tags +- ระบบจะใช้ Elasticsearch Engine ในการจัดทำดัชนีเพื่อการค้นหาข้อมูลเชิงลึกจากเนื้อหาของเอกสาร โดยข้อมูลจะถูกส่งไปทำดัชนีจาก Backend (NestJS) ทุกครั้งที่มีการสร้างหรือแก้ไขเอกสาร #### 3.9.12 **DocumentNumberingModule:** -* **สถานะ:** เป็น Module ภายใน (Internal Module) ไม่เปิด API สู่ภายนอก -* **หน้าที่:** ให้บริการ DocumentNumberingService ที่ Module อื่น (เช่น CorrespondenceModule) จะ Inject ไปใช้งาน -* **ตรรกะ:** รับผิดชอบการสร้างเลขที่เอกสารโดยใช้ **Redis distributed locking** แทน stored procedure -* **Features:** - * Application-level locking เพื่อป้องกัน race condition - * Retry mechanism ด้วย exponential backoff - * Fallback mechanism เมื่อการขอเลขล้มเหลว - * Audit log ทุกครั้งที่มีการ generate เลขที่เอกสารใหม่ +- **สถานะ:** เป็น Module ภายใน (Internal Module) ไม่เปิด API สู่ภายนอก +- **หน้าที่:** ให้บริการ DocumentNumberingService ที่ Module อื่น (เช่น CorrespondenceModule) จะ Inject ไปใช้งาน +- **ตรรกะ:** รับผิดชอบการสร้างเลขที่เอกสารโดยใช้ **Redis distributed locking** แทน stored procedure +- **Features:** + - Application-level locking เพื่อป้องกัน race condition + - Retry mechanism ด้วย exponential backoff + - Fallback mechanism เมื่อการขอเลขล้มเหลว + - Audit log ทุกครั้งที่มีการ generate เลขที่เอกสารใหม่ #### 3.9.13 **CorrespondenceRoutingModule:** -* **สถานะ:** โมดูลหลักสำหรับจัดการการส่งต่อเอกสาร -* **หน้าที่:** จัดการแม่แบบการส่งต่อและการส่งต่อจริง -* **Entities:** - * CorrespondenceRoutingTemplate - * CorrespondenceRoutingTemplateStep - * CorrespondenceRouting -* **Features:** - * สร้างและจัดการแม่แบบการส่งต่อ - * ดำเนินการส่งต่อเอกสารตามแม่แบบ - * ติดตามสถานะการส่งต่อ - * คำนวณวันครบกำหนดอัตโนมัติ - * ส่งการแจ้งเตือนเมื่อมีการส่งต่อใหม่ +- **สถานะ:** โมดูลหลักสำหรับจัดการการส่งต่อเอกสาร +- **หน้าที่:** จัดการแม่แบบการส่งต่อและการส่งต่อจริง +- **Entities:** + - CorrespondenceRoutingTemplate + - CorrespondenceRoutingTemplateStep + - CorrespondenceRouting +- **Features:** + - สร้างและจัดการแม่แบบการส่งต่อ + - ดำเนินการส่งต่อเอกสารตามแม่แบบ + - ติดตามสถานะการส่งต่อ + - คำนวณวันครบกำหนดอัตโนมัติ + - ส่งการแจ้งเตือนเมื่อมีการส่งต่อใหม่ #### 3.9.14 **WorkflowEngineModule:** -* **สถานะ:** Internal Module สำหรับจัดการ workflow logic -* **หน้าที่:** ประมวลผล state transitions และ business rules -* **Features:** - * State machine สำหรับสถานะเอกสาร - * Validation rules สำหรับการเปลี่ยนสถานะ - * Automatic status updates - * Deadline management และ escalation +- **สถานะ:** Internal Module สำหรับจัดการ workflow logic +- **หน้าที่:** ประมวลผล state transitions และ business rules +- **Features:** + - State machine สำหรับสถานะเอกสาร + - Validation rules สำหรับการเปลี่ยนสถานะ + - Automatic status updates + - Deadline management และ escalation #### 3.9.15 **JsonSchemaModule:** -* **สถานะ:** Internal Module สำหรับจัดการ JSON schemas -* **หน้าที่:** Validate, transform, และ manage JSON data structures -* **Features:** - * JSON schema validation ด้วย AJV - * Schema versioning และ migration - * Dynamic schema generation - * Data transformation และ sanitization +- **สถานะ:** Internal Module สำหรับจัดการ JSON schemas +- **หน้าที่:** Validate, transform, และ manage JSON data structures +- **Features:** + - JSON schema validation ด้วย AJV + - Schema versioning และ migration + - Dynamic schema generation + - Data transformation และ sanitization #### 3.9.16 **DetailsService:** -* **สถานะ:** Shared Service สำหรับจัดการ details fields -* **หน้าที่:** Centralized service สำหรับ JSON details operations -* **Methods:** - * validateDetails(type: string, data: any): ValidationResult - * transformDetails(input: any, targetVersion: string): any - * sanitizeDetails(data: any): any - * getDefaultDetails(type: string): any +- **สถานะ:** Shared Service สำหรับจัดการ details fields +- **หน้าที่:** Centralized service สำหรับ JSON details operations +- **Methods:** + - validateDetails(type: string, data: any): ValidationResult + - transformDetails(input: any, targetVersion: string): any + - sanitizeDetails(data: any): any + - getDefaultDetails(type: string): any ### **3.10 สถาปัตยกรรมระบบ (System Architecture)** @@ -393,151 +394,157 @@ CREATE INDEX idx_ref_project_id ON correspondence_revisions(ref_project_id); ### **3.11 กลยุทธ์ความทนทานและการจัดการข้อผิดพลาด (Resilience & Error Handling Strategy)** -* **Circuit Breaker Pattern:** ใช้สำหรับ external service calls (Email, LINE, Elasticsearch) -* **Retry Mechanism:** ด้วย exponential backoff สำหรับ transient failures -* **Fallback Strategies:** Graceful degradation เมื่อบริการภายนอกล้มเหลว -* **Error Handling:** Error messages ต้องไม่เปิดเผยข้อมูล sensitive -* **Monitoring:** Centralized error monitoring และ alerting system +- **Circuit Breaker Pattern:** ใช้สำหรับ external service calls (Email, LINE, Elasticsearch) +- **Retry Mechanism:** ด้วย exponential backoff สำหรับ transient failures +- **Fallback Strategies:** Graceful degradation เมื่อบริการภายนอกล้มเหลว +- **Error Handling:** Error messages ต้องไม่เปิดเผยข้อมูล sensitive +- **Monitoring:** Centralized error monitoring และ alerting system ### **3.12 FileStorageService (ปรับปรุงใหม่):** -* **Virus Scanning:** Integrate ClamAV สำหรับ scan ไฟล์ที่อัปโหลดทั้งหมด -* **File Type Validation:** ใช้ white-list approach (PDF, DWG, DOCX, XLSX, ZIP) -* **File Size Limits:** 50MB ต่อไฟล์ -* **Security Measures:** - * เก็บไฟล์นอก web root - * Download ผ่าน authenticated endpoint เท่านั้น - * Download links มี expiration time (24 ชั่วโมง) - * File integrity checks (checksum) - * Access control checks ก่อนดาวน์โหลด +- **Virus Scanning:** Integrate ClamAV สำหรับ scan ไฟล์ที่อัปโหลดทั้งหมด +- **File Type Validation:** ใช้ white-list approach (PDF, DWG, DOCX, XLSX, ZIP) +- **File Size Limits:** 50MB ต่อไฟล์ +- **Security Measures:** + - เก็บไฟล์นอก web root + - Download ผ่าน authenticated endpoint เท่านั้น + - Download links มี expiration time (24 ชั่วโมง) + - File integrity checks (checksum) + - Access control checks ก่อนดาวน์โหลด ### **3.13 เเทคโนโลยีที่ใช้ (Technology Stack)** -| ส่วน | Library/Tool | หมายเหตุ | -| ----------------------- | ---------------------------------------------------- | -------------------------------------- | -| **Framework** | `@nestjs/core`, `@nestjs/common` | Core Framework | -| **Language** | `TypeScript` | ใช้ TypeScript ทั้งระบบ | -| **Database** | `MariaDB 10.11` | ฐานข้อมูลหลัก | -| **ORM** | `@nestjs/typeorm`, `typeorm` | 🗃️จัดการการเชื่อมต่อและ Query ฐานข้อมูล | -| **Validation** | `class-validator`, `class-transformer` | 📦ตรวจสอบและแปลงข้อมูลใน DTO | -| **Auth** | `@nestjs/jwt`, `@nestjs/passport`, `passport-jwt` | 🔐การยืนยันตัวตนด้วย JWT | -| **Authorization** | `casl` | 🔐จัดการสิทธิ์แบบ RBAC | -| **File Upload** | `multer` | 📁จัดการการอัปโหลดไฟล์ | -| **Search** | `@nestjs/elasticsearch` | 🔍สำหรับการค้นหาขั้นสูง | -| **Notification** | `nodemailer` | 📬ส่งอีเมลแจ้งเตือน | +| ส่วน | Library/Tool | หมายเหตุ | +| ----------------------- | ---------------------------------------------------- | -------------------------------------------- | +| **Framework** | `@nestjs/core`, `@nestjs/common` | Core Framework | +| **Language** | `TypeScript` | ใช้ TypeScript ทั้งระบบ | +| **Database** | `MariaDB 10.11` | ฐานข้อมูลหลัก | +| **ORM** | `@nestjs/typeorm`, `typeorm` | 🗃️จัดการการเชื่อมต่อและ Query ฐานข้อมูล | +| **Validation** | `class-validator`, `class-transformer` | 📦ตรวจสอบและแปลงข้อมูลใน DTO | +| **Auth** | `@nestjs/jwt`, `@nestjs/passport`, `passport-jwt` | 🔐การยืนยันตัวตนด้วย JWT | +| **Authorization** | `casl` | 🔐จัดการสิทธิ์แบบ RBAC | +| **File Upload** | `multer` | 📁จัดการการอัปโหลดไฟล์ | +| **Search** | `@nestjs/elasticsearch` | 🔍สำหรับการค้นหาขั้นสูง | +| **Notification** | `nodemailer` | 📬ส่งอีเมลแจ้งเตือน | | **Scheduling** | `@nestjs/schedule` | 📬สำหรับ Cron Jobs (เช่น แจ้งเตือน Deadline) | -| **Logging** | `winston` | 📊บันทึก Log ที่มีประสิทธิภาพ | -| **Testing** | `@nestjs/testing`, `jest`, `supertest` | 🧪ทดสอบ Unit, Integration และ E2E | -| **Documentation** | `@nestjs/swagger` | 🌐สร้าง API Documentation อัตโนมัติ | -| **Security** | `helmet`, `rate-limiter-flexible` | 🛡️เพิ่มความปลอดภัยให้ API | -| **Resilience** | `@nestjs/circuit-breaker` | 🔄 Circuit breaker pattern | -| **Caching** | `@nestjs/cache-manager`, `cache-manager-redis-store` | 💾 Distributed caching | -| **Security** | `helmet`, `csurf`, `rate-limiter-flexible` | 🛡️ Security enhancements | -| **Validation** | `class-validator`, `class-transformer` | ✅ Input validation | -| **Monitoring** | `@nestjs/monitoring`, `winston` | 📊 Application monitoring | -| **File Processing** | `clamscan` | 🦠 Virus scanning | -| **Cryptography** | `bcrypt`, `crypto` | 🔐 Password hashing และ checksums | -| **JSON Validation** | `ajv`, `ajv-formats` | 🎯 JSON schema validation | -| **JSON Processing** | `jsonpath`, `json-schema-ref-parser` | 🔧 JSON manipulation | -| **Data Transformation** | `class-transformer` | 🔄 Object transformation | -| **Compression** | `compression` | 📦 JSON compression | +| **Logging** | `winston` | 📊บันทึก Log ที่มีประสิทธิภาพ | +| **Testing** | `@nestjs/testing`, `jest`, `supertest` | 🧪ทดสอบ Unit, Integration และ E2E | +| **Documentation** | `@nestjs/swagger` | 🌐สร้าง API Documentation อัตโนมัติ | +| **Security** | `helmet`, `rate-limiter-flexible` | 🛡️เพิ่มความปลอดภัยให้ API | +| **Resilience** | `@nestjs/circuit-breaker` | 🔄 Circuit breaker pattern | +| **Caching** | `@nestjs/cache-manager`, `cache-manager-redis-store` | 💾 Distributed caching | +| **Security** | `helmet`, `csurf`, `rate-limiter-flexible` | 🛡️ Security enhancements | +| **Validation** | `class-validator`, `class-transformer` | ✅ Input validation | +| **Monitoring** | `@nestjs/monitoring`, `winston` | 📊 Application monitoring | +| **File Processing** | `clamscan` | 🦠 Virus scanning | +| **Cryptography** | `bcrypt`, `crypto` | 🔐 Password hashing และ checksums | +| **JSON Validation** | `ajv`, `ajv-formats` | 🎯 JSON schema validation | +| **JSON Processing** | `jsonpath`, `json-schema-ref-parser` | 🔧 JSON manipulation | +| **Data Transformation** | `class-transformer` | 🔄 Object transformation | +| **Compression** | `compression` | 📦 JSON compression | ### **3.14 Security Testing:** -* **Penetration Testing:** ทดสอบ OWASP Top 10 vulnerabilities -* **Security Audit:** Review code สำหรับ security flaws -* **Virus Scanning Test:** ทดสอบ file upload security -* **Rate Limiting Test:** ทดสอบ rate limiting functionality +- **Penetration Testing:** ทดสอบ OWASP Top 10 vulnerabilities +- **Security Audit:** Review code สำหรับ security flaws +- **Virus Scanning Test:** ทดสอบ file upload security +- **Rate Limiting Test:** ทดสอบ rate limiting functionality ### **3.15 Performance Testing:** -* **Load Testing:** ทดสอบด้วย realistic workloads -* **Stress Testing:** หา breaking points ของระบบ -* **Endurance Testing:** ทดสอบการทำงานต่อเนื่องเป็นเวลานาน +- **Load Testing:** ทดสอบด้วย realistic workloads +- **Stress Testing:** หา breaking points ของระบบ +- **Endurance Testing:** ทดสอบการทำงานต่อเนื่องเป็นเวลานาน ### 🗄️**3.16 Backend State Management** Backend (NestJS) ควรเป็น **Stateless** (ไม่เก็บสถานะ) "State" ทั้งหมดจะถูกจัดเก็บใน MariaDB -* **Request-Scoped State (สถานะภายใน Request เดียว):** - * **ปัญหา:** จะส่งต่อข้อมูล (เช่น User ที่ล็อกอิน) ระหว่าง Guard และ Service ใน Request เดียวกันได้อย่างไร? - * **วิธีแก้:** ใช้ **Request-Scoped Providers** ของ NestJS (เช่น AuthContextService) เพื่อเก็บข้อมูล User ปัจจุบันที่ได้จาก AuthGuard และให้ Service อื่น Inject ไปใช้ -* **Application-Scoped State (การ Caching):** - * **ปัญหา:** ข้อมูล Master (เช่น roles, permissions, organizations) ถูกเรียกใช้บ่อย - * **วิธีแก้:** ใช้ **Caching** (เช่น @nestjs/cache-manager) เพื่อ Caching ข้อมูลเหล่านี้ และลดภาระ Database +- **Request-Scoped State (สถานะภายใน Request เดียว):** + - **ปัญหา:** จะส่งต่อข้อมูล (เช่น User ที่ล็อกอิน) ระหว่าง Guard และ Service ใน Request เดียวกันได้อย่างไร? + - **วิธีแก้:** ใช้ **Request-Scoped Providers** ของ NestJS (เช่น AuthContextService) เพื่อเก็บข้อมูล User ปัจจุบันที่ได้จาก AuthGuard และให้ Service อื่น Inject ไปใช้ +- **Application-Scoped State (การ Caching):** + - **ปัญหา:** ข้อมูล Master (เช่น roles, permissions, organizations) ถูกเรียกใช้บ่อย + - **วิธีแก้:** ใช้ **Caching** (เช่น @nestjs/cache-manager) เพื่อ Caching ข้อมูลเหล่านี้ และลดภาระ Database ### **3.17 Caching Strategy (ตามข้อ 6.4.2):** -* **Master Data Cache:** Roles, Permissions, Organizations (TTL: 1 hour) -* **User Session Cache:** User permissions และ profile (TTL: 30 minutes) -* **Search Result Cache:** Frequently searched queries (TTL: 15 minutes) -* **File Metadata Cache:** Attachment metadata (TTL: 1 hour) -* **Cache Invalidation:** Clear cache on update/delete operations +- **Master Data Cache:** Roles, Permissions, Organizations (TTL: 1 hour) +- **User Session Cache:** User permissions และ profile (TTL: 30 minutes) +- **Search Result Cache:** Frequently searched queries (TTL: 15 minutes) +- **File Metadata Cache:** Attachment metadata (TTL: 1 hour) +- **Cache Invalidation:** Clear cache on update/delete operations ### **3.18 การไหลของข้อมูล (Data Flow)** #### **3.18.1 Main Flow:** - 1. Request: ผ่าน Nginx Proxy Manager -> NestJS Controller - 2. **Rate Limiting:** RateLimitGuard ตรวจสอบ request limits - 3. **Input Validation:** Validation Pipe ตรวจสอบและ sanitize inputs - 4. Authentication: JWT Guard ตรวจสอบ Token และดึงข้อมูล User - 5. Authorization: RBAC Guard ตรวจสอบสิทธิ์ - 6. **Security Checks:** Virus scanning (สำหรับ file upload), XSS protection - 7. Business Logic: Service Layer ประมวลผลตรรกะทางธุรกิจ - 8. **Resilience:** Circuit breaker และ retry logic สำหรับ external calls - 9. Data Access: Repository Layer ติดต่อกับฐานข้อมูล - 10. **Caching:** Cache frequently accessed data - 11. **Audit Log:** บันทึกการกระทำสำคัญ - 12. Response: ส่งกลับไปยัง Frontend +1. Request: ผ่าน Nginx Proxy Manager -> NestJS Controller +2. **Rate Limiting:** RateLimitGuard ตรวจสอบ request limits +3. **Input Validation:** Validation Pipe ตรวจสอบและ sanitize inputs +4. Authentication: JWT Guard ตรวจสอบ Token และดึงข้อมูล User +5. Authorization: RBAC Guard ตรวจสอบสิทธิ์ +6. **Security Checks:** Virus scanning (สำหรับ file upload), XSS protection +7. Business Logic: Service Layer ประมวลผลตรรกะทางธุรกิจ +8. **Resilience:** Circuit breaker และ retry logic สำหรับ external calls +9. Data Access: Repository Layer ติดต่อกับฐานข้อมูล +10. **Caching:** Cache frequently accessed data +11. **Audit Log:** บันทึกการกระทำสำคัญ +12. Response: ส่งกลับไปยัง Frontend #### **3.18.2 Workflow Data Flow:** - 1. User สร้างเอกสาร → เลือก routing template - 2. System สร้าง routing instances ตาม template - 3. สำหรับแต่ละ routing step: +1. User สร้างเอกสาร → เลือก routing template +2. System สร้าง routing instances ตาม template +3. สำหรับแต่ละ routing step: + + - กำหนด due date (จาก expected_days) - ส่ง notification ไปยังองค์กรผู้รับ - อัพเดทสถานะเป็น SENT - 4. เมื่อองค์กรผู้รับดำเนินการ: + +4. เมื่อองค์กรผู้รับดำเนินการ: + + - อัพเดทสถานะเป็น ACTIONED/FORWARDED/REPLIED - บันทึก processed_by และ processed_at - ส่ง notification ไปยังขั้นตอนต่อไป (ถ้ามี) - 5. เมื่อครบทุกขั้นตอน → อัพเดทสถานะเอกสารเป็น COMPLETED + +5. เมื่อครบทุกขั้นตอน → อัพเดทสถานะเอกสารเป็น COMPLETED #### **3.18.3 JSON Details Processing Flow:** - 1. **Receive Request** → Get JSON data from client - 2. **Schema Validation** → Validate against predefined schema - 3. **Data Sanitization** → Sanitize and transform data - 4. **Version Check** → Handle schema version compatibility - 5. **Storage** → Store validated JSON in database - 6. **Retrieval** → Retrieve and transform on demand +1. **Receive Request** → Get JSON data from client +2. **Schema Validation** → Validate against predefined schema +3. **Data Sanitization** → Sanitize and transform data +4. **Version Check** → Handle schema version compatibility +5. **Storage** → Store validated JSON in database +6. **Retrieval** → Retrieve and transform on demand ### 📊**3.19 Monitoring & Observability (ตามข้อ 6.8)** #### **Application Monitoring:** -* **Health Checks:** `/health` endpoint สำหรับ load balancer -* **Metrics Collection:** Response times, error rates, throughput -* **Distributed Tracing:** สำหรับ request tracing across services -* **Log Aggregation:** Structured logging ด้วย JSON format -* **Alerting:** สำหรับ critical errors และ performance degradation +- **Health Checks:** `/health` endpoint สำหรับ load balancer +- **Metrics Collection:** Response times, error rates, throughput +- **Distributed Tracing:** สำหรับ request tracing across services +- **Log Aggregation:** Structured logging ด้วย JSON format +- **Alerting:** สำหรับ critical errors และ performance degradation #### **Business Metrics:** -* จำนวน documents created ต่อวัน -* Workflow completion rates -* User activity metrics -* System utilization rates -* Search query performance +- จำนวน documents created ต่อวัน +- Workflow completion rates +- User activity metrics +- System utilization rates +- Search query performance #### **Performance Targets:** -* API Response Time: < 200ms (90th percentile) -* Search Query Performance: < 500ms -* File Upload Performance: < 30 seconds สำหรับไฟล์ 50MB -* Cache Hit Ratio: > 80% +- API Response Time: < 200ms (90th percentile) +- Search Query Performance: < 500ms +- File Upload Performance: < 30 seconds สำหรับไฟล์ 50MB +- Cache Hit Ratio: > 80% ## 🖥️ **4. ฟรอนต์เอนด์ (Next.js) - Implementation Details** @@ -557,11 +564,12 @@ export const useDraftStore = create( (set) => ({ drafts: {}, saveDraft: (key, data) => set((state) => ({ drafts: { ...state.drafts, [key]: data } })), - clearDraft: (key) => set((state) => { - const newDrafts = { ...state.drafts }; - delete newDrafts[key]; - return { drafts: newDrafts }; - }), + clearDraft: (key) => + set((state) => { + const newDrafts = { ...state.drafts }; + delete newDrafts[key]; + return { drafts: newDrafts }; + }), }), { name: 'form-drafts' } ) @@ -572,15 +580,15 @@ export const useDraftStore = create( เพื่อรองรับ JSON Schema หลากหลายรูปแบบ ให้สร้าง Component กลางที่รับ Schema แล้ว Gen Form ออกมา (ลดการแก้ Code บ่อยๆ) -* **Libraries:** แนะนำ `react-jsonschema-form` หรือสร้าง Wrapper บน `react-hook-form` ที่ Recursively render field ตาม Type -* **Validation:** ใช้ `ajv` ที่ฝั่ง Client เพื่อ Validate JSON ก่อน Submit +- **Libraries:** แนะนำ `react-jsonschema-form` หรือสร้าง Wrapper บน `react-hook-form` ที่ Recursively render field ตาม Type +- **Validation:** ใช้ `ajv` ที่ฝั่ง Client เพื่อ Validate JSON ก่อน Submit ### **4.3 Mobile Responsiveness (Card View)** ตารางข้อมูล (`DataTable`) ต้องมีความฉลาดในการแสดงผล: -* **Desktop:** แสดงเป็น Table ปกติ -* **Mobile:** แปลงเป็น **Card View** โดยอัตโนมัติ (ซ่อน Header, แสดง Label คู่ Value ในแต่ละ Card) +- **Desktop:** แสดงเป็น Table ปกติ +- **Mobile:** แปลงเป็น **Card View** โดยอัตโนมัติ (ซ่อน Header, แสดง Label คู่ Value ในแต่ละ Card) ```tsx // components/ui/responsive-table.tsx @@ -602,79 +610,79 @@ export const useDraftStore = create( ### **4.5 แนวทางการพัฒนาโค้ด (Code Implementation Guidelines)** -* ใช้ **early returns** เพื่อความชัดเจน -* ใช้คลาสของ **TailwindCSS** ในการกำหนดสไตล์เสมอ -* ควรใช้ class: syntax แบบมีเงื่อนไข (หรือ utility clsx) มากกว่าการใช้ ternary operators ใน class strings -* ใช้ **const arrow functions** สำหรับ components และ handlers -* Event handlers ให้ขึ้นต้นด้วย handle... (เช่น handleClick, handleSubmit) -* รวมแอตทริบิวต์สำหรับการเข้าถึง (accessibility) ด้วย: +- ใช้ **early returns** เพื่อความชัดเจน +- ใช้คลาสของ **TailwindCSS** ในการกำหนดสไตล์เสมอ +- ควรใช้ class: syntax แบบมีเงื่อนไข (หรือ utility clsx) มากกว่าการใช้ ternary operators ใน class strings +- ใช้ **const arrow functions** สำหรับ components และ handlers +- Event handlers ให้ขึ้นต้นด้วย handle... (เช่น handleClick, handleSubmit) +- รวมแอตทริบิวต์สำหรับการเข้าถึง (accessibility) ด้วย: tabIndex="0", aria-label, onKeyDown, ฯลฯ -* ตรวจสอบให้แน่ใจว่าโค้ดทั้งหมด **สมบูรณ์**, **ผ่านการทดสอบ**, และ **ไม่ซ้ำซ้อน (DRY)** -* ต้อง import โมดูลที่จำเป็นต้องใช้อย่างชัดเจนเสมอ +- ตรวจสอบให้แน่ใจว่าโค้ดทั้งหมด **สมบูรณ์**, **ผ่านการทดสอบ**, และ **ไม่ซ้ำซ้อน (DRY)** +- ต้อง import โมดูลที่จำเป็นต้องใช้อย่างชัดเจนเสมอ ### **4.6 UI/UX ด้วย React** -* ใช้ **semantic HTML** -* ใช้คลาสของ **Tailwind** ที่รองรับ responsive (sm:, md:, lg:) -* รักษาลำดับชั้นของการมองเห็น (visual hierarchy) ด้วยการใช้ typography และ spacing -* ใช้ **Shadcn** components (Button, Input, Card, ฯลฯ) เพื่อ UI ที่สอดคล้องกัน -* ทำให้ components มีขนาดเล็กและมุ่งเน้นการทำงานเฉพาะอย่าง -* ใช้ utility classes สำหรับการจัดสไตล์อย่างรวดเร็ว (spacing, colors, text, ฯลฯ) -* ตรวจสอบให้แน่ใจว่าสอดคล้องกับ **ARIA** และใช้ semantic markup +- ใช้ **semantic HTML** +- ใช้คลาสของ **Tailwind** ที่รองรับ responsive (sm:, md:, lg:) +- รักษาลำดับชั้นของการมองเห็น (visual hierarchy) ด้วยการใช้ typography และ spacing +- ใช้ **Shadcn** components (Button, Input, Card, ฯลฯ) เพื่อ UI ที่สอดคล้องกัน +- ทำให้ components มีขนาดเล็กและมุ่งเน้นการทำงานเฉพาะอย่าง +- ใช้ utility classes สำหรับการจัดสไตล์อย่างรวดเร็ว (spacing, colors, text, ฯลฯ) +- ตรวจสอบให้แน่ใจว่าสอดคล้องกับ **ARIA** และใช้ semantic markup ### **4.7 การตรวจสอบฟอร์มและข้อผิดพลาด (Form Validation & Errors)** -* ใช้ไลบรารีฝั่ง client เช่น zod และ react-hook-form -* แสดงข้อผิดพลาดด้วย **alert components** หรือข้อความ inline -* ต้องมี labels, placeholders, และข้อความ feedback +- ใช้ไลบรารีฝั่ง client เช่น zod และ react-hook-form +- แสดงข้อผิดพลาดด้วย **alert components** หรือข้อความ inline +- ต้องมี labels, placeholders, และข้อความ feedback ### **🧪4.8 Frontend Testing** เราจะใช้ **React Testing Library (RTL)** สำหรับการทดสอบ Component และ **Playwright** สำหรับ E2E: -* **Unit Tests (การทดสอบหน่วยย่อย):** - * **เครื่องมือ:** Vitest + RTL - * **เป้าหมาย:** ทดสอบ Component ขนาดเล็ก (เช่น Buttons, Inputs) หรือ Utility functions -* **Integration Tests (การทดสอบการบูรณาการ):** - * **เครื่องมือ:** RTL + **Mock Service Worker (MSW)** - * **เป้าหมาย:** ทดสอบว่า Component หรือ Page ทำงานกับ API (ที่จำลองขึ้น) ได้ถูกต้อง - * **เทคนิค:** ใช้ MSW เพื่อจำลอง NestJS API และทดสอบว่า Component แสดงผลข้อมูลจำลองได้ถูกต้องหรือไม่ (เช่น ทดสอบหน้า Dashboard [cite: 5.3] ที่ดึงข้อมูลจาก v_user_tasks) -* **E2E (End-to-End) Tests:** - * **เครื่องมือ:** **Playwright** - * **เป้าหมาย:** ทดสอบ User Flow ทั้งระบบโดยอัตโนมัติ (เช่น ล็อกอิน -> สร้าง RFA -> ตรวจสอบ Workflow Visualization [cite: 5.6]) +- **Unit Tests (การทดสอบหน่วยย่อย):** + - **เครื่องมือ:** Vitest + RTL + - **เป้าหมาย:** ทดสอบ Component ขนาดเล็ก (เช่น Buttons, Inputs) หรือ Utility functions +- **Integration Tests (การทดสอบการบูรณาการ):** + - **เครื่องมือ:** RTL + **Mock Service Worker (MSW)** + - **เป้าหมาย:** ทดสอบว่า Component หรือ Page ทำงานกับ API (ที่จำลองขึ้น) ได้ถูกต้อง + - **เทคนิค:** ใช้ MSW เพื่อจำลอง NestJS API และทดสอบว่า Component แสดงผลข้อมูลจำลองได้ถูกต้องหรือไม่ (เช่น ทดสอบหน้า Dashboard [cite: 5.3] ที่ดึงข้อมูลจาก v_user_tasks) +- **E2E (End-to-End) Tests:** + - **เครื่องมือ:** **Playwright** + - **เป้าหมาย:** ทดสอบ User Flow ทั้งระบบโดยอัตโนมัติ (เช่น ล็อกอิน -> สร้าง RFA -> ตรวจสอบ Workflow Visualization [cite: 5.6]) ### **🗄️4.9 Frontend State Management** สำหรับ Next.js App Router เราจะแบ่ง State เป็น 4 ระดับ: 1. **Local UI State (สถานะ UI ชั่วคราว):** - * **เครื่องมือ:** useState, useReducer - * **ใช้เมื่อ:** จัดการสถานะเล็กๆ ที่จบใน Component เดียว (เช่น Modal เปิด/ปิด, ค่าใน Input) + - **เครื่องมือ:** useState, useReducer + - **ใช้เมื่อ:** จัดการสถานะเล็กๆ ที่จบใน Component เดียว (เช่น Modal เปิด/ปิด, ค่าใน Input) 2. **Server State (สถานะข้อมูลจากเซิร์ฟเวอร์):** - * **เครื่องมือ:** **React Query (TanStack Query)** หรือ SWR - * **ใช้เมื่อ:** จัดการข้อมูลที่ดึงมาจาก NestJS API (เช่น รายการ correspondences, rfas, drawings) - * **ทำไม:** React Query เป็น "Cache" ที่จัดการ Caching, Re-fetching, และ Invalidation ให้โดยอัตโนมัติ + - **เครื่องมือ:** **React Query (TanStack Query)** หรือ SWR + - **ใช้เมื่อ:** จัดการข้อมูลที่ดึงมาจาก NestJS API (เช่น รายการ correspondences, rfas, drawings) + - **ทำไม:** React Query เป็น "Cache" ที่จัดการ Caching, Re-fetching, และ Invalidation ให้โดยอัตโนมัติ 3. **Global Client State (สถานะส่วนกลางฝั่ง Client):** - * **เครื่องมือ:** **Zustand** (แนะนำ) หรือ Context API - * **ใช้เมื่อ:** จัดการข้อมูลที่ต้องใช้ร่วมกันทั่วทั้งแอป และ *ไม่ใช่* ข้อมูลจากเซิร์ฟเวอร์ (เช่น ข้อมูล User ที่ล็อกอิน, สิทธิ์ Permissions) + - **เครื่องมือ:** **Zustand** (แนะนำ) หรือ Context API + - **ใช้เมื่อ:** จัดการข้อมูลที่ต้องใช้ร่วมกันทั่วทั้งแอป และ _ไม่ใช่_ ข้อมูลจากเซิร์ฟเวอร์ (เช่น ข้อมูล User ที่ล็อกอิน, สิทธิ์ Permissions) 4. **Form State (สถานะของฟอร์ม):** - * **เครื่องมือ:** **React Hook Form** + **Zod** - * **ใช้เมื่อ:** จัดการฟอร์มที่ซับซ้อน (เช่น ฟอร์มสร้าง RFA, ฟอร์ม Circulation [cite: 3.7]) + - **เครื่องมือ:** **React Hook Form** + **Zod** + - **ใช้เมื่อ:** จัดการฟอร์มที่ซับซ้อน (เช่น ฟอร์มสร้าง RFA, ฟอร์ม Circulation [cite: 3.7]) ## 🔐 **5. Security & Access Control (Full Stack Integration)** ### **5.1 CASL Integration (Shared Ability)** -* **Backend:** ใช้ CASL กำหนด Permission Rule -* **Frontend:** ให้ดึง Rule (JSON) จาก Backend มา Load ใส่ `@casl/react` เพื่อให้ Logic การ Show/Hide ปุ่ม ตรงกัน 100% +- **Backend:** ใช้ CASL กำหนด Permission Rule +- **Frontend:** ให้ดึง Rule (JSON) จาก Backend มา Load ใส่ `@casl/react` เพื่อให้ Logic การ Show/Hide ปุ่ม ตรงกัน 100% ### **5.2 Maintenance Mode** เพิ่ม Middleware (ทั้ง NestJS และ Next.js) เพื่อตรวจสอบ Flag ใน Redis: -* ถ้า `MAINTENANCE_MODE = true` -* **API:** Return `503 Service Unavailable` (ยกเว้น Admin IP) -* **Frontend:** Redirect ไปหน้า `/maintenance` +- ถ้า `MAINTENANCE_MODE = true` +- **API:** Return `503 Service Unavailable` (ยกเว้น Admin IP) +- **Frontend:** Redirect ไปหน้า `/maintenance` ### **5.3 Idempotency Client** @@ -706,20 +714,20 @@ updateRFA(@Param('id') id: string) { #### **5.4.1 Roles (บทบาท)** -* **Superadmin**: ไม่มีข้อจำกัดใดๆ [cite: 4.3] -* **Admin**: มีสิทธิ์เต็มที่ในองค์กร [cite: 4.3] -* **Document Control**: เพิ่ม/แก้ไข/ลบ เอกสารในองค์กร [cite: 4.3] -* **Editor**: สามารถ เพิ่ม/แก้ไข เอกสารที่กำหนด [cite: 4.3] -* **Viewer**: สามารถดู เอกสาร [cite: 4.3] +- **Superadmin**: ไม่มีข้อจำกัดใดๆ [cite: 4.3] +- **Admin**: มีสิทธิ์เต็มที่ในองค์กร [cite: 4.3] +- **Document Control**: เพิ่ม/แก้ไข/ลบ เอกสารในองค์กร [cite: 4.3] +- **Editor**: สามารถ เพิ่ม/แก้ไข เอกสารที่กำหนด [cite: 4.3] +- **Viewer**: สามารถดู เอกสาร [cite: 4.3] #### **5.4.2 ตัวอย่าง Permissions (จากตาราง permissions)** -* rfas.view, rfas.create, rfas.respond, rfas.delete -* drawings.view, drawings.upload, drawings.delete -* corr.view, corr.manage -* transmittals.manage -* cirs.manage -* project_parties.manage +- rfas.view, rfas.create, rfas.respond, rfas.delete +- drawings.view, drawings.upload, drawings.delete +- corr.view, corr.manage +- transmittals.manage +- cirs.manage +- project_parties.manage การจับคู่ระหว่าง roles และ permissions **เริ่มต้น** จะถูก seed ผ่านสคริปต์ (ดังที่เห็นในไฟล์ SQL)**อย่างไรก็ตาม AuthModule/UserModule ต้องมี API สำหรับ Admin เพื่อสร้าง Role ใหม่และกำหนดสิทธิ์ (Permissions) เพิ่มเติมได้ในภายหลัง** [cite: 4.3] @@ -735,15 +743,15 @@ updateRFA(@Param('id') id: string) { ## 🔗 **7. แนวทางการบูรณาการ Full Stack (Full Stack Integration Guidelines)** -| Aspect (แง่มุม) | Backend (NestJS) | Frontend (NextJS) | UI Layer (Tailwind/Shadcn) | -| :----------------------- | :------------------------- | :---------------------------- | :------------------------------------- | -| API | REST / GraphQL Controllers | API hooks ผ่าน fetch/axios/SWR | Components ที่รับข้อมูล | -| Validation (การตรวจสอบ) | class-validator DTOs | zod / react-hook-form | สถานะของฟอร์ม/input ใน Shadcn | -| Auth (การยืนยันตัวตน) | Guards, JWT | NextAuth / cookies | สถานะ UI ของ Auth (loading, signed in) | -| Errors (ข้อผิดพลาด) | Global filters | Toasts / modals | Alerts / ข้อความ feedback | -| Testing (การทดสอบ) | Jest (unit/e2e) | Vitest / Playwright | Visual regression | -| Styles (สไตล์) | Scoped modules (ถ้าจำเป็น) | Tailwind / Shadcn | Tailwind utilities | -| Accessibility (การเข้าถึง) | Guards + filters | ARIA attributes | Semantic HTML | +| Aspect (แง่มุม) | Backend (NestJS) | Frontend (NextJS) | UI Layer (Tailwind/Shadcn) | +| :------------------------- | :------------------------- | :----------------------------- | :------------------------------------- | +| API | REST / GraphQL Controllers | API hooks ผ่าน fetch/axios/SWR | Components ที่รับข้อมูล | +| Validation (การตรวจสอบ) | class-validator DTOs | zod / react-hook-form | สถานะของฟอร์ม/input ใน Shadcn | +| Auth (การยืนยันตัวตน) | Guards, JWT | NextAuth / cookies | สถานะ UI ของ Auth (loading, signed in) | +| Errors (ข้อผิดพลาด) | Global filters | Toasts / modals | Alerts / ข้อความ feedback | +| Testing (การทดสอบ) | Jest (unit/e2e) | Vitest / Playwright | Visual regression | +| Styles (สไตล์) | Scoped modules (ถ้าจำเป็น) | Tailwind / Shadcn | Tailwind utilities | +| Accessibility (การเข้าถึง) | Guards + filters | ARIA attributes | Semantic HTML | ## 🗂️ **8. ข้อตกลงเฉพาะสำหรับ DMS (LCBP3-DMS)** @@ -753,33 +761,33 @@ updateRFA(@Param('id') id: string) { บันทึกการดำเนินการ CRUD และการจับคู่ทั้งหมดลงในตาราง audit_logs -| Field (ฟิลด์) | Type (จาก SQL) | Description (คำอธิบาย) | -| :----------- | :------------- | :----------------------------------------------- | -| audit_id | BIGINT | Primary Key | -| user_id | INT | ผู้ใช้ที่ดำเนินการ (FK -> users) | -| action | VARCHAR(100) | rfa.create, correspondence.update, login.success | -| entity_type | VARCHAR(50) | ชื่อตาราง/โมดูล เช่น 'rfa', 'correspondence' | -| entity_id | VARCHAR(50) | Primary ID ของระเบียนที่ได้รับผลกระทบ | -| details_json | JSON | ข้อมูลบริบท (เช่น ฟิลด์ที่มีการเปลี่ยนแปลง) | -| ip_address | VARCHAR(45) | IP address ของผู้ดำเนินการ | -| user_agent | VARCHAR(255) | User Agent ของผู้ดำเนินการ | -| created_at | TIMESTAMP | Timestamp (UTC) | +| Field (ฟิลด์) | Type (จาก SQL) | Description (คำอธิบาย) | +| :------------ | :------------- | :----------------------------------------------- | +| audit_id | BIGINT | Primary Key | +| user_id | INT | ผู้ใช้ที่ดำเนินการ (FK -> users) | +| action | VARCHAR(100) | rfa.create, correspondence.update, login.success | +| entity_type | VARCHAR(50) | ชื่อตาราง/โมดูล เช่น 'rfa', 'correspondence' | +| entity_id | VARCHAR(50) | Primary ID ของระเบียนที่ได้รับผลกระทบ | +| details_json | JSON | ข้อมูลบริบท (เช่น ฟิลด์ที่มีการเปลี่ยนแปลง) | +| ip_address | VARCHAR(45) | IP address ของผู้ดำเนินการ | +| user_agent | VARCHAR(255) | User Agent ของผู้ดำเนินการ | +| created_at | TIMESTAMP | Timestamp (UTC) | ### 📂**8.2 การจัดการไฟล์ (File Handling)** #### **8.2.1 มาตรฐานการอัปโหลดไฟล์ (File Upload Standard)** -* **Security-First Approach:** การอัปโหลดไฟล์ทั้งหมดจะถูกจัดการโดย FileStorageService ที่มี security measures ครบถ้วน -* ไฟล์จะถูกเชื่อมโยงไปยัง Entity ที่ถูกต้องผ่าน **ตารางเชื่อม (Junction Tables)** เท่านั้น: - * correspondence_attachments (เชื่อม Correspondence กับ Attachments) - * circulation_attachments (เชื่อม Circulation กับ Attachments) - * shop_drawing_revision_attachments (เชื่อม Shop Drawing Revision กับ Attachments) - * contract_drawing_attachments (เชื่อม Contract Drawing กับ Attachments) -* เส้นทางจัดเก็บไฟล์ (Upload path): อ้างอิงจาก Requirement 2.1 คือ /share/dms-data [cite: 2.1] โดย FileStorageService จะสร้างโฟลเดอร์ย่อยแบบรวมศูนย์ (เช่น /share/dms-data/uploads/{YYYY}/{MM}/[stored_filename]) -* ประเภทไฟล์ที่อนุญาต: pdf, dwg, docx, xlsx, zip (ผ่าน white-list validation) -* ขนาดสูงสุด: **50 MB** -* จัดเก็บนอก webroot -* ให้บริการไฟล์ผ่าน endpoint ที่ปลอดภัย /files/:attachment_id/download +- **Security-First Approach:** การอัปโหลดไฟล์ทั้งหมดจะถูกจัดการโดย FileStorageService ที่มี security measures ครบถ้วน +- ไฟล์จะถูกเชื่อมโยงไปยัง Entity ที่ถูกต้องผ่าน **ตารางเชื่อม (Junction Tables)** เท่านั้น: + - correspondence_attachments (เชื่อม Correspondence กับ Attachments) + - circulation_attachments (เชื่อม Circulation กับ Attachments) + - shop_drawing_revision_attachments (เชื่อม Shop Drawing Revision กับ Attachments) + - contract_drawing_attachments (เชื่อม Contract Drawing กับ Attachments) +- เส้นทางจัดเก็บไฟล์ (Upload path): อ้างอิงจาก Requirement 2.1 คือ /share/dms-data [cite: 2.1] โดย FileStorageService จะสร้างโฟลเดอร์ย่อยแบบรวมศูนย์ (เช่น /share/dms-data/uploads/{YYYY}/{MM}/[stored_filename]) +- ประเภทไฟล์ที่อนุญาต: pdf, dwg, docx, xlsx, zip (ผ่าน white-list validation) +- ขนาดสูงสุด: **50 MB** +- จัดเก็บนอก webroot +- ให้บริการไฟล์ผ่าน endpoint ที่ปลอดภัย /files/:attachment_id/download #### **8.2.2 Security Controls สำหรับ File Access:** @@ -793,16 +801,16 @@ updateRFA(@Param('id') id: string) { ### 🔟**8.3 การจัดการเลขที่เอกสาร (Document Numbering) [cite: 3.10]** -* **เป้าหมาย:** สร้างเลขที่เอกสาร (เช่น correspondence_number) โดยอัตโนมัติ ตามรูปแบบที่กำหนด -* **ตรรกะการนับ:** การนับ Running number (SEQ) จะนับแยกตาม Key: **Project + Originator Organization + Document Type + Year** -* **ตาราง SQL:** - * document_number_formats: Admin ใช้กำหนด "รูปแบบ" (Template) ของเลขที่ (เช่น {ORG_CODE}-{TYPE_CODE}-{YEAR_SHORT}-{SEQ:4}) โดยกำหนดตาม **Project** และ **Document Type** [cite: 4.5] - * document_number_counters: ระบบใช้เก็บ "ตัวนับ" ล่าสุดของ Key (Project+Org+Type+Year) -* **การทำงาน (Backend):** - * DocumentNumberingModule จะให้บริการ DocumentNumberingService - * เมื่อ CorrespondenceModule ต้องการสร้างเอกสารใหม่, มันจะเรียก documentNumberingService.generateNextNumber(...) - * Service นี้จะใช้ **Redis distributed locking** แทน stored procedure ซึ่งจะจัดการ Database Transaction และ Row Locking ภายใน Application Layer เพื่อรับประกันการป้องกัน Race Condition - * มี retry mechanism และ fallback strategies +- **เป้าหมาย:** สร้างเลขที่เอกสาร (เช่น correspondence_number) โดยอัตโนมัติ ตามรูปแบบที่กำหนด +- **ตรรกะการนับ:** การนับ Running number (SEQ) จะนับแยกตาม Key: **Project + Originator Organization + Document Type + Year** +- **ตาราง SQL:** + - document_number_formats: Admin ใช้กำหนด "รูปแบบ" (Template) ของเลขที่ (เช่น {ORG_CODE}-{TYPE_CODE}-{YEAR_SHORT}-{SEQ:4}) โดยกำหนดตาม **Project** และ **Document Type** [cite: 4.5] + - document_number_counters: ระบบใช้เก็บ "ตัวนับ" ล่าสุดของ Key (Project+Org+Type+Year) +- **การทำงาน (Backend):** + - DocumentNumberingModule จะให้บริการ DocumentNumberingService + - เมื่อ CorrespondenceModule ต้องการสร้างเอกสารใหม่, มันจะเรียก documentNumberingService.generateNextNumber(...) + - Service นี้จะใช้ **Redis distributed locking** แทน stored procedure ซึ่งจะจัดการ Database Transaction และ Row Locking ภายใน Application Layer เพื่อรับประกันการป้องกัน Race Condition + - มี retry mechanism และ fallback strategies ### 📊**8.4 การรายงานและการส่งออก (Reporting & Exports)** @@ -810,114 +818,114 @@ updateRFA(@Param('id') id: string) { การรายงานควรสร้างขึ้นจาก Views ที่กำหนดไว้ล่วงหน้าในฐานข้อมูลเป็นหลัก: -* v_current_correspondences: สำหรับ revision ปัจจุบันทั้งหมดของเอกสารที่ไม่ใช่ RFA -* v_current_rfas: สำหรับ revision ปัจจุบันทั้งหมดของ RFA และข้อมูล master -* v_contract_parties_all: สำหรับการตรวจสอบความสัมพันธ์ของ project/contract/organization -* v_user_tasks: สำหรับ Dashboard "งานของฉัน" -* v_audit_log_details: สำหรับ Activity Feed +- v_current_correspondences: สำหรับ revision ปัจจุบันทั้งหมดของเอกสารที่ไม่ใช่ RFA +- v_current_rfas: สำหรับ revision ปัจจุบันทั้งหมดของ RFA และข้อมูล master +- v_contract_parties_all: สำหรับการตรวจสอบความสัมพันธ์ของ project/contract/organization +- v_user_tasks: สำหรับ Dashboard "งานของฉัน" +- v_audit_log_details: สำหรับ Activity Feed Views เหล่านี้ทำหน้าที่เป็นแหล่งข้อมูลหลักสำหรับการรายงานฝั่งเซิร์ฟเวอร์และการส่งออกข้อมูล #### **8.4.2 กฎการส่งออก (Export Rules)** -* Export formats: CSV, Excel, PDF. -* จัดเตรียมมุมมองสำหรับพิมพ์ (Print view). -* รวมลิงก์ไปยังต้นทาง (เช่น /rfas/:id). +- Export formats: CSV, Excel, PDF. +- จัดเตรียมมุมมองสำหรับพิมพ์ (Print view). +- รวมลิงก์ไปยังต้นทาง (เช่น /rfas/:id). ## 🧮 **9. ฟรอนต์เอนด์: รูปแบบ DataTable และฟอร์ม (Frontend: DataTable & Form Patterns)** ### **9.1 DataTable (Server‑Side)** -* Endpoint: /api/{module}?page=1&pageSize=20&sort=...&filter=... -* ต้องรองรับ: การแบ่งหน้า (pagination), การเรียงลำดับ (sorting), การค้นหา (search), การกรอง (filters) -* แสดง revision ล่าสุดแบบ inline เสมอ (สำหรับ RFA/Drawing) +- Endpoint: /api/{module}?page=1&pageSize=20&sort=...&filter=... +- ต้องรองรับ: การแบ่งหน้า (pagination), การเรียงลำดับ (sorting), การค้นหา (search), การกรอง (filters) +- แสดง revision ล่าสุดแบบ inline เสมอ (สำหรับ RFA/Drawing) ### **9.2 มาตรฐานฟอร์ม (Form Standards)** -* ต้องมีการใช้งาน Dropdowns แบบขึ้นต่อกัน (Dependent dropdowns) (ตามที่สคีมารองรับ): - * Project → Contract Drawing Volumes - * Contract Drawing Category → Sub-Category - * RFA (ประเภท Shop Drawing) → Shop Drawing Revisions ที่เชื่อมโยงได้ -* **File Upload Security:** ต้องรองรับ **Multi-file upload (Drag-and-Drop)** [cite: 5.7] พร้อม virus scanning feedback -* **File Type Indicators:** UI ต้องอนุญาตให้ผู้ใช้กำหนดว่าไฟล์ใดเป็น **"เอกสารหลัก"** หรือ "เอกสารแนบประกอบ" [cite: 5.7] พร้อมแสดง file type icons -* **Security Feedback:** แสดง security warnings สำหรับ file types ที่เสี่ยงหรือ files ที่ fail virus scan -* ส่ง (Submit) ผ่าน API พร้อม feedback แบบ toast +- ต้องมีการใช้งาน Dropdowns แบบขึ้นต่อกัน (Dependent dropdowns) (ตามที่สคีมารองรับ): + - Project → Contract Drawing Volumes + - Contract Drawing Category → Sub-Category + - RFA (ประเภท Shop Drawing) → Shop Drawing Revisions ที่เชื่อมโยงได้ +- **File Upload Security:** ต้องรองรับ **Multi-file upload (Drag-and-Drop)** [cite: 5.7] พร้อม virus scanning feedback +- **File Type Indicators:** UI ต้องอนุญาตให้ผู้ใช้กำหนดว่าไฟล์ใดเป็น **"เอกสารหลัก"** หรือ "เอกสารแนบประกอบ" [cite: 5.7] พร้อมแสดง file type icons +- **Security Feedback:** แสดง security warnings สำหรับ file types ที่เสี่ยงหรือ files ที่ fail virus scan +- ส่ง (Submit) ผ่าน API พร้อม feedback แบบ toast ### **9.3 ข้อกำหนด Component เฉพาะ (Specific UI Requirements)** -* **Dashboard - My Tasks:** ต้องพัฒนา Component ตาราง "งานของฉัน" (My Tasks)ซึ่งดึงข้อมูลงานที่ผู้ใช้ล็อกอินอยู่ต้องรับผิดชอบ (Main/Action) จาก v_user_tasks [cite: 5.3] -* **Workflow Visualization:** ต้องพัฒนา Component สำหรับแสดงผล Workflow (โดยเฉพาะ RFA)ที่แสดงขั้นตอนทั้งหมดเป็นลำดับ โดยขั้นตอนปัจจุบัน (active) เท่านั้นที่ดำเนินการได้ และขั้นตอนอื่นเป็น disabled [cite: 5.6] ต้องมีตรรกะสำหรับ Admin ในการ override หรือย้อนกลับขั้นตอนได้ [cite: 5.6] -* **Admin Panel:** ต้องมีหน้า UI สำหรับ Superadmin/Admin เพื่อจัดการข้อมูลหลัก (Master Data [cite: 4.5]), การเริ่มต้นใช้งาน (Onboarding [cite: 4.6]), และ **รูปแบบเลขที่เอกสาร (Numbering Formats [cite: 3.10])** -* **Security Dashboard:** แสดง security metrics และ audit logs สำหรับ administrators +- **Dashboard - My Tasks:** ต้องพัฒนา Component ตาราง "งานของฉัน" (My Tasks)ซึ่งดึงข้อมูลงานที่ผู้ใช้ล็อกอินอยู่ต้องรับผิดชอบ (Main/Action) จาก v_user_tasks [cite: 5.3] +- **Workflow Visualization:** ต้องพัฒนา Component สำหรับแสดงผล Workflow (โดยเฉพาะ RFA)ที่แสดงขั้นตอนทั้งหมดเป็นลำดับ โดยขั้นตอนปัจจุบัน (active) เท่านั้นที่ดำเนินการได้ และขั้นตอนอื่นเป็น disabled [cite: 5.6] ต้องมีตรรกะสำหรับ Admin ในการ override หรือย้อนกลับขั้นตอนได้ [cite: 5.6] +- **Admin Panel:** ต้องมีหน้า UI สำหรับ Superadmin/Admin เพื่อจัดการข้อมูลหลัก (Master Data [cite: 4.5]), การเริ่มต้นใช้งาน (Onboarding [cite: 4.6]), และ **รูปแบบเลขที่เอกสาร (Numbering Formats [cite: 3.10])** +- **Security Dashboard:** แสดง security metrics และ audit logs สำหรับ administrators ## 🧭 **10. แดชบอร์ดและฟีดกิจกรรม (Dashboard & Activity Feed)** ### **10.1 การ์ดบนแดชบอร์ด (Dashboard Cards)** -* แสดง Correspondences, RFAs, Circulations, Shop Drawing Revision ล่าสุด -* รวมสรุป KPI (เช่น "RFAs ที่รอการอนุมัติ", "Shop Drawing ที่รอการอนุมัติ") [cite: 5.3] -* รวมลิงก์ด่วนไปยังโมดูลต่างๆ -* **Security Metrics:** แสดงจำนวน files scanned, security incidents, failed login attempts +- แสดง Correspondences, RFAs, Circulations, Shop Drawing Revision ล่าสุด +- รวมสรุป KPI (เช่น "RFAs ที่รอการอนุมัติ", "Shop Drawing ที่รอการอนุมัติ") [cite: 5.3] +- รวมลิงก์ด่วนไปยังโมดูลต่างๆ +- **Security Metrics:** แสดงจำนวน files scanned, security incidents, failed login attempts ### **10.2 ฟีดกิจกรรม (Activity Feed)** -* แสดงรายการ v_audit_log_details ล่าสุด (10 รายการ) ที่เกี่ยวข้องกับผู้ใช้ -* รวม security-related activities (failed logins, permission changes) +- แสดงรายการ v_audit_log_details ล่าสุด (10 รายการ) ที่เกี่ยวข้องกับผู้ใช้ +- รวม security-related activities (failed logins, permission changes) ```typescript // ตัวอย่าง API response [ { user: 'editor01', action: 'Updated RFA (LCBP3-RFA-001)', time: '2025-11-04T09:30Z' }, - { user: 'system', action: 'Virus scan completed - 0 threats found', time: '2025-11-04T09:25Z' } -] + { user: 'system', action: 'Virus scan completed - 0 threats found', time: '2025-11-04T09:25Z' }, +]; ``` ## 🛡️ **11. ข้อกำหนดที่ไม่ใช่ฟังก์ชันการทำงาน (Non-Functional Requirements)** ส่วนนี้สรุปข้อกำหนด Non-Functional จาก requirements.md เพื่อให้ทีมพัฒนาทาน -* **Audit Log [cite: 6.1]:** ทุกการกระทำที่สำคัญ (C/U/D) ต้องถูกบันทึกใน audit_logs -* **Performance [cite: 6.4]:** ต้องใช้ Caching สำหรับข้อมูลที่เรียกบ่อย และใช้ Pagination -* **Security [cite: 6.5]:** ต้องมี Rate Limiting และจัดการ Secret ผ่าน docker-compose.yml (ไม่ใช่ .env) -* **File Security [cite: 3.9.6]:** ต้องมี virus scanning, file type validation, access controls -* **Resilience [cite: 6.5.3]:** ต้องมี circuit breaker, retry mechanisms, graceful degradation -* **Backup & Recovery [cite: 6.6]:** ต้องมีแผนสำรองข้อมูลทั้ง Database (MariaDB) และ File Storage (/share/dms-data) อย่างน้อยวันละ 1 ครั้ง -* **Notification Strategy [cite: 6.7]:** ระบบแจ้งเตือน (Email/Line) ต้องถูก Trigger เมื่อมีเอกสารใหม่ส่งถึง, มีการมอบหมายงานใหม่ (Circulation), หรือ (ทางเลือก) เมื่องานเสร็จ/ใกล้ถึงกำหนด -* **Monitoring [cite: 6.8]:** ต้องมี health checks, metrics collection, alerting +- **Audit Log [cite: 6.1]:** ทุกการกระทำที่สำคัญ (C/U/D) ต้องถูกบันทึกใน audit_logs +- **Performance [cite: 6.4]:** ต้องใช้ Caching สำหรับข้อมูลที่เรียกบ่อย และใช้ Pagination +- **Security [cite: 6.5]:** ต้องมี Rate Limiting และจัดการ Secret ผ่าน docker-compose.yml (ไม่ใช่ .env) +- **File Security [cite: 3.9.6]:** ต้องมี virus scanning, file type validation, access controls +- **Resilience [cite: 6.5.3]:** ต้องมี circuit breaker, retry mechanisms, graceful degradation +- **Backup & Recovery [cite: 6.6]:** ต้องมีแผนสำรองข้อมูลทั้ง Database (MariaDB) และ File Storage (/share/dms-data) อย่างน้อยวันละ 1 ครั้ง +- **Notification Strategy [cite: 6.7]:** ระบบแจ้งเตือน (Email/Line) ต้องถูก Trigger เมื่อมีเอกสารใหม่ส่งถึง, มีการมอบหมายงานใหม่ (Circulation), หรือ (ทางเลือก) เมื่องานเสร็จ/ใกล้ถึงกำหนด +- **Monitoring [cite: 6.8]:** ต้องมี health checks, metrics collection, alerting ## ✅ **12. มาตรฐานที่นำไปใช้แล้ว (จาก SQL v1.4.0) (Implemented Standards (from SQL v1.4.0))** ส่วนนี้ยืนยันว่าแนวทางปฏิบัติที่ดีที่สุดต่อไปนี้เป็นส่วนหนึ่งของการออกแบบฐานข้อมูลอยู่แล้ว และควรถูกนำไปใช้ประโยชน์ ไม่ใช่สร้างขึ้นใหม่ -* ✅ **Soft Delete:** นำไปใช้แล้วผ่านคอลัมน์ deleted_at ในตารางสำคัญ (เช่น correspondences, rfas, project_parties) ตรรกะการดึงข้อมูลต้องกรอง deleted_at IS NULL -* ✅ **Database Indexes:** สคีมาได้มีการทำ index ไว้อย่างหนักหน่วงบน foreign keys และคอลัมน์ที่ใช้ค้นหาบ่อย (เช่น idx_rr_rfa, idx_cor_project, idx_cr_is_current) เพื่อประสิทธิภาพ -* ✅ **โครงสร้าง RBAC:** มีระบบ users, roles, permissions, user_roles, และ user_project_roles ที่ครอบคลุมอยู่แล้ว -* ✅ **Data Seeding:** ข้อมูล Master (roles, permissions, organization_roles, initial users, project parties) ถูกรวมอยู่ในสคริปต์สคีมาแล้ว -* ✅ **Application-level Locking:** ใช้ Redis distributed lock แทน stored procedure -* ✅ **File Security:** Virus scanning, file type validation, access control -* ✅ **Resilience Patterns:** Circuit breaker, retry, fallback mechanisms -* ✅ **Security Measures:** Input validation, rate limiting, security headers -* ✅ **Monitoring:** Health checks, metrics collection, distributed tracing +- ✅ **Soft Delete:** นำไปใช้แล้วผ่านคอลัมน์ deleted_at ในตารางสำคัญ (เช่น correspondences, rfas, project_parties) ตรรกะการดึงข้อมูลต้องกรอง deleted_at IS NULL +- ✅ **Database Indexes:** สคีมาได้มีการทำ index ไว้อย่างหนักหน่วงบน foreign keys และคอลัมน์ที่ใช้ค้นหาบ่อย (เช่น idx_rr_rfa, idx_cor_project, idx_cr_is_current) เพื่อประสิทธิภาพ +- ✅ **โครงสร้าง RBAC:** มีระบบ users, roles, permissions, user_roles, และ user_project_roles ที่ครอบคลุมอยู่แล้ว +- ✅ **Data Seeding:** ข้อมูล Master (roles, permissions, organization_roles, initial users, project parties) ถูกรวมอยู่ในสคริปต์สคีมาแล้ว +- ✅ **Application-level Locking:** ใช้ Redis distributed lock แทน stored procedure +- ✅ **File Security:** Virus scanning, file type validation, access control +- ✅ **Resilience Patterns:** Circuit breaker, retry, fallback mechanisms +- ✅ **Security Measures:** Input validation, rate limiting, security headers +- ✅ **Monitoring:** Health checks, metrics collection, distributed tracing ## 🧩 **13. การปรับปรุงที่แนะนำ (สำหรับอนาคต) (Recommended Enhancements (Future))** -* ✅ สร้าง Background job (โดยใช้ **n8n** เพื่อเชื่อมต่อกับ **Line** [cite: 2.7] และ/หรือใช้สำหรับการแจ้งเตือน RFA ที่ใกล้ถึงกำหนด due_date [cite: 6.7]) -* ✅ เพิ่ม job ล้างข้อมูลเป็นระยะสำหรับ attachments ที่ไม่ถูกเชื่อมโยงกับ Entity ใดๆ เลย (ไฟล์กำพร้า) -* 🔄 **AI-Powered Document Classification:** ใช้ machine learning สำหรับ automatic document categorization -* 🔄 **Advanced Analytics:** Predictive analytics สำหรับ workflow optimization -* 🔄 **Mobile App:** Native mobile application สำหรับ field workers -* 🔄 **Blockchain Integration:** สำหรับ document integrity verification ที่ต้องการความปลอดภัยสูงสุด +- ✅ สร้าง Background job (โดยใช้ **n8n** เพื่อเชื่อมต่อกับ **Line** [cite: 2.7] และ/หรือใช้สำหรับการแจ้งเตือน RFA ที่ใกล้ถึงกำหนด due_date [cite: 6.7]) +- ✅ เพิ่ม job ล้างข้อมูลเป็นระยะสำหรับ attachments ที่ไม่ถูกเชื่อมโยงกับ Entity ใดๆ เลย (ไฟล์กำพร้า) +- 🔄 **AI-Powered Document Classification:** ใช้ machine learning สำหรับ automatic document categorization +- 🔄 **Advanced Analytics:** Predictive analytics สำหรับ workflow optimization +- 🔄 **Mobile App:** Native mobile application สำหรับ field workers +- 🔄 **Blockchain Integration:** สำหรับ document integrity verification ที่ต้องการความปลอดภัยสูงสุด ## ✅ **14. Summary Checklist for Developers** ก่อนส่ง PR (Pull Request) นักพัฒนาต้องตรวจสอบหัวข้อต่อไปนี้: -* [ ] **Security:** ไม่มี Secrets ใน Code, ใช้ `docker-compose.override.yml` แล้ว -* [ ] **Concurrency:** ใช้ Optimistic Lock ใน Entity ที่เสี่ยง Race Condition แล้ว -* [ ] **Idempotency:** API รองรับ Idempotency Key แล้ว -* [ ] **File Upload:** ใช้ Flow Two-Phase (Temp -> Perm) แล้ว -* [ ] **Mobile:** หน้าจอแสดงผลแบบ Card View บนมือถือได้ถูกต้อง -* [ ] **Performance:** สร้าง Index สำหรับ JSON Virtual Columns แล้ว (ถ้ามี) +- [ ] **Security:** ไม่มี Secrets ใน Code, ใช้ `docker-compose.override.yml` แล้ว +- [ ] **Concurrency:** ใช้ Optimistic Lock ใน Entity ที่เสี่ยง Race Condition แล้ว +- [ ] **Idempotency:** API รองรับ Idempotency Key แล้ว +- [ ] **File Upload:** ใช้ Flow Two-Phase (Temp -> Perm) แล้ว +- [ ] **Mobile:** หน้าจอแสดงผลแบบ Card View บนมือถือได้ถูกต้อง +- [ ] **Performance:** สร้าง Index สำหรับ JSON Virtual Columns แล้ว (ถ้ามี) --- @@ -957,13 +965,13 @@ Views เหล่านี้ทำหน้าที่เป็นแหล ## **Document Control:** -* **Document:** FullStackJS v1.4.3 -* **Version:** 1.4 -* **Date:** 2025-11-22 -* **Author:** NAP LCBP3-DMS & Gemini -* **Status:** FINAL -* **Classification:** Internal Technical Documentation -* **Approved By:** Nattanin +- **Document:** FullStackJS v1.4.3 +- **Version:** 1.4 +- **Date:** 2025-11-22 +- **Author:** NAP LCBP3-DMS & Gemini +- **Status:** FINAL +- **Classification:** Internal Technical Documentation +- **Approved By:** Nattanin --- diff --git a/specs/99-archives/docs/Markdown/1_FullStackJS_V1_4_4.md b/specs/99-archives/docs/Markdown/1_FullStackJS_V1_4_4.md index ba8c633..ed6e27e 100644 --- a/specs/99-archives/docs/Markdown/1_FullStackJS_V1_4_4.md +++ b/specs/99-archives/docs/Markdown/1_FullStackJS_V1_4_4.md @@ -9,100 +9,101 @@ แนวทางปฏิบัติที่ดีที่สุดแบบครบวงจรสำหรับการพัฒนา NestJS Backend, NextJS Frontend และ Tailwind-based UI/UX ในสภาพแวดล้อม TypeScript มุ่งเน้นที่ **"Data Integrity First"** (ความถูกต้องของข้อมูลต้องมาก่อน) ตามด้วย Security และ UX -* **ความชัดเจน (clarity), ความง่ายในการบำรุงรักษา (maintainability), ความสอดคล้องกัน (consistency) และ การเข้าถึงได้ (accessibility)** ตลอดทั้งสแต็ก -* **Strict Typing:** ใช้ TypeScript อย่างเคร่งครัด ห้าม `any` -* **Consistency:** ใช้ภาษาอังกฤษใน Code / ภาษาไทยใน Comment -* **Resilience:** ระบบต้องทนทานต่อ Network Failure และ Race Condition +- **ความชัดเจน (clarity), ความง่ายในการบำรุงรักษา (maintainability), ความสอดคล้องกัน (consistency) และ การเข้าถึงได้ (accessibility)** ตลอดทั้งสแต็ก +- **Strict Typing:** ใช้ TypeScript อย่างเคร่งครัด ห้าม `any` +- **Consistency:** ใช้ภาษาอังกฤษใน Code / ภาษาไทยใน Comment +- **Resilience:** ระบบต้องทนทานต่อ Network Failure และ Race Condition ## ⚙️ **2. แนวทางทั่วไปสำหรับ TypeScript** ### **2.1 หลักการพื้นฐาน** -* ใช้ **ภาษาอังกฤษ** สำหรับโค้ด -* ใช้ **ภาษาไทย** สำหรับ comment และเอกสารทั้งหมด -* กำหนดไทป์ (type) อย่างชัดเจนสำหรับตัวแปร, พารามิเตอร์ และค่าที่ส่งกลับ (return values) ทั้งหมด -* หลีกเลี่ยงการใช้ any; ให้สร้างไทป์ (types) หรืออินเทอร์เฟซ (interfaces) ที่กำหนดเอง -* ใช้ **JSDoc** สำหรับคลาส (classes) และเมธอด (methods) ที่เป็น public -* ส่งออก (Export) **สัญลักษณ์หลัก (main symbol) เพียงหนึ่งเดียว** ต่อไฟล์ -* หลีกเลี่ยงบรรทัดว่างภายในฟังก์ชัน -* ระบุ // File: path/filename ในบรรทัดแรกของทุกไฟล์ -* ระบุ // บันทึกการแก้ไข, หากมีการแก้ไขเพิ่มในอนาคต ให้เพิ่มบันทึก +- ใช้ **ภาษาอังกฤษ** สำหรับโค้ด +- ใช้ **ภาษาไทย** สำหรับ comment และเอกสารทั้งหมด +- กำหนดไทป์ (type) อย่างชัดเจนสำหรับตัวแปร, พารามิเตอร์ และค่าที่ส่งกลับ (return values) ทั้งหมด +- หลีกเลี่ยงการใช้ any; ให้สร้างไทป์ (types) หรืออินเทอร์เฟซ (interfaces) ที่กำหนดเอง +- ใช้ **JSDoc** สำหรับคลาส (classes) และเมธอด (methods) ที่เป็น public +- ส่งออก (Export) **สัญลักษณ์หลัก (main symbol) เพียงหนึ่งเดียว** ต่อไฟล์ +- หลีกเลี่ยงบรรทัดว่างภายในฟังก์ชัน +- ระบุ // File: path/filename ในบรรทัดแรกของทุกไฟล์ +- ระบุ // บันทึกการแก้ไข, หากมีการแก้ไขเพิ่มในอนาคต ให้เพิ่มบันทึก ### **2.2 Configuration & Secrets Management** -* **Production/Staging:** ห้ามใส่ Secrets (Password, Keys) ใน `docker-compose.yml` หลัก -* **Development:** ให้สร้างไฟล์ `docker-compose.override.yml` (เพิ่มใน `.gitignore`) เพื่อ Inject ตัวแปร Environment ที่เป็นความลับ -* **Validation:** ใช้ `joi` หรือ `zod` ในการ Validate Environment Variables ตอน Start App หากขาดตัวแปรสำคัญให้ Throw Error ทันที +- **Production/Staging:** ห้ามใส่ Secrets (Password, Keys) ใน `docker-compose.yml` หลัก +- **Development:** ให้สร้างไฟล์ `docker-compose.override.yml` (เพิ่มใน `.gitignore`) เพื่อ Inject ตัวแปร Environment ที่เป็นความลับ +- **Validation:** ใช้ `joi` หรือ `zod` ในการ Validate Environment Variables ตอน Start App หากขาดตัวแปรสำคัญให้ Throw Error ทันที ### **2.3 Idempotency (ความสามารถในการทำซ้ำได้)** -* สำหรับการทำงานที่สำคัญ (Create Document, Approve, Transactional) **ต้อง** ออกแบบให้เป็น Idempotent -* Client **ต้อง** ส่ง Header `Idempotency-Key` (UUID) มากับ Request -* Server **ต้อง** ตรวจสอบว่า Key นี้เคยถูกประมวลผลสำเร็จไปแล้วหรือไม่ ถ้าใช่ ให้คืนค่าเดิมโดยไม่ทำซ้ำ +- สำหรับการทำงานที่สำคัญ (Create Document, Approve, Transactional) **ต้อง** ออกแบบให้เป็น Idempotent +- Client **ต้อง** ส่ง Header `Idempotency-Key` (UUID) มากับ Request +- Server **ต้อง** ตรวจสอบว่า Key นี้เคยถูกประมวลผลสำเร็จไปแล้วหรือไม่ ถ้าใช่ ให้คืนค่าเดิมโดยไม่ทำซ้ำ ### **2.4 ข้อตกลงในการตั้งชื่อ (Naming Conventions)** -| Entity (สิ่งที่ตั้งชื่อ) | Convention (รูปแบบ) | Example (ตัวอย่าง) | -| :-------------------- | :----------------- | :--------------------------------- | -| Classes | PascalCase | UserService | -| Property | snake_case | user_id | -| Variables & Functions | camelCase | getUserInfo | -| Files & Folders | kebab-case | user-service.ts | -| Environment Variables | UPPERCASE | DATABASE_URL | -| Booleans | Verb + Noun | isActive, canDelete, hasPermission | +| Entity (สิ่งที่ตั้งชื่อ) | Convention (รูปแบบ) | Example (ตัวอย่าง) | +| :----------------------- | :------------------ | :--------------------------------- | +| Classes | PascalCase | UserService | +| Property | snake_case | user_id | +| Variables & Functions | camelCase | getUserInfo | +| Files & Folders | kebab-case | user-service.ts | +| Environment Variables | UPPERCASE | DATABASE_URL | +| Booleans | Verb + Noun | isActive, canDelete, hasPermission | ใช้คำเต็ม — ไม่ใช้อักษรย่อ — ยกเว้นคำมาตรฐาน (เช่น API, URL, req, res, err, ctx) ### 🧩**2.5 ฟังก์ชัน (Functions)** -* เขียนฟังก์ชันให้สั้น และทำ **หน้าที่เพียงอย่างเดียว** (single-purpose) (\< 20 บรรทัด) -* ใช้ **early returns** เพื่อลดการซ้อน (nesting) ของโค้ด -* ใช้ **map**, **filter**, **reduce** แทนการใช้ loops เมื่อเหมาะสม -* ควรใช้ **arrow functions** สำหรับตรรกะสั้นๆ, และใช้ **named functions** ในกรณีอื่น -* ใช้ **default parameters** แทนการตรวจสอบค่า null -* จัดกลุ่มพารามิเตอร์หลายตัวให้เป็นอ็อบเจกต์เดียว (RO-RO pattern) -* ส่งค่ากลับ (Return) เป็นอ็อบเจกต์ที่มีไทป์กำหนด (typed objects) ไม่ใช่ค่าพื้นฐาน (primitives) -* รักษาระดับของสิ่งที่เป็นนามธรรม (abstraction level) ให้เป็นระดับเดียวในแต่ละฟังก์ชัน +- เขียนฟังก์ชันให้สั้น และทำ **หน้าที่เพียงอย่างเดียว** (single-purpose) (\< 20 บรรทัด) +- ใช้ **early returns** เพื่อลดการซ้อน (nesting) ของโค้ด +- ใช้ **map**, **filter**, **reduce** แทนการใช้ loops เมื่อเหมาะสม +- ควรใช้ **arrow functions** สำหรับตรรกะสั้นๆ, และใช้ **named functions** ในกรณีอื่น +- ใช้ **default parameters** แทนการตรวจสอบค่า null +- จัดกลุ่มพารามิเตอร์หลายตัวให้เป็นอ็อบเจกต์เดียว (RO-RO pattern) +- ส่งค่ากลับ (Return) เป็นอ็อบเจกต์ที่มีไทป์กำหนด (typed objects) ไม่ใช่ค่าพื้นฐาน (primitives) +- รักษาระดับของสิ่งที่เป็นนามธรรม (abstraction level) ให้เป็นระดับเดียวในแต่ละฟังก์ชัน ### 🧱**2.6 การจัดการข้อมูล (Data Handling)** -* ห่อหุ้มข้อมูล (Encapsulate) ในไทป์แบบผสม (composite types) -* ใช้ **immutability** (การไม่เปลี่ยนแปลงค่า) ด้วย readonly และ as const -* ทำการตรวจสอบความถูกต้องของข้อมูล (Validations) ในคลาสหรือ DTOs ไม่ใช่ภายในฟังก์ชันทางธุรกิจ -* ตรวจสอบความถูกต้องของข้อมูลโดยใช้ DTOs ที่มีไทป์กำหนดเสมอ +- ห่อหุ้มข้อมูล (Encapsulate) ในไทป์แบบผสม (composite types) +- ใช้ **immutability** (การไม่เปลี่ยนแปลงค่า) ด้วย readonly และ as const +- ทำการตรวจสอบความถูกต้องของข้อมูล (Validations) ในคลาสหรือ DTOs ไม่ใช่ภายในฟังก์ชันทางธุรกิจ +- ตรวจสอบความถูกต้องของข้อมูลโดยใช้ DTOs ที่มีไทป์กำหนดเสมอ ### 🧰**2.7 คลาส (Classes)** -* ปฏิบัติตามหลักการ **SOLID** -* ควรใช้ **composition มากกว่า inheritance** (Prefer composition over inheritance) -* กำหนด **interfaces** สำหรับสัญญา (contracts) -* ให้คลาสมุ่งเน้นการทำงานเฉพาะอย่างและมีขนาดเล็ก (\< 200 บรรทัด, \< 10 เมธอด, \< 10 properties) +- ปฏิบัติตามหลักการ **SOLID** +- ควรใช้ **composition มากกว่า inheritance** (Prefer composition over inheritance) +- กำหนด **interfaces** สำหรับสัญญา (contracts) +- ให้คลาสมุ่งเน้นการทำงานเฉพาะอย่างและมีขนาดเล็ก (\< 200 บรรทัด, \< 10 เมธอด, \< 10 properties) ### 🚨**2.8 การจัดการข้อผิดพลาด (Error Handling)** -* ใช้ Exceptions สำหรับข้อผิดพลาดที่ไม่คาดคิด -* ดักจับ (Catch) ข้อผิดพลาดเพื่อแก้ไขหรือเพิ่มบริบท (context) เท่านั้น; หากไม่เช่นนั้น ให้ใช้ global error handlers -* ระบุข้อความข้อผิดพลาด (error messages) ที่มีความหมายเสมอ +- ใช้ Exceptions สำหรับข้อผิดพลาดที่ไม่คาดคิด +- ดักจับ (Catch) ข้อผิดพลาดเพื่อแก้ไขหรือเพิ่มบริบท (context) เท่านั้น; หากไม่เช่นนั้น ให้ใช้ global error handlers +- ระบุข้อความข้อผิดพลาด (error messages) ที่มีความหมายเสมอ ### 🧪**2.9 การทดสอบ (ทั่วไป) (Testing (General))** -* ใช้รูปแบบ **Arrange–Act–Assert** -* ใช้ชื่อตัวแปรในการทดสอบที่สื่อความหมาย (inputData, expectedOutput) -* เขียน **unit tests** สำหรับ public methods ทั้งหมด -* จำลอง (Mock) การพึ่งพาภายนอก (external dependencies) -* เพิ่ม **acceptance tests** ต่อโมดูลโดยใช้รูปแบบ Given–When-Then +- ใช้รูปแบบ **Arrange–Act–Assert** +- ใช้ชื่อตัวแปรในการทดสอบที่สื่อความหมาย (inputData, expectedOutput) +- เขียน **unit tests** สำหรับ public methods ทั้งหมด +- จำลอง (Mock) การพึ่งพาภายนอก (external dependencies) +- เพิ่ม **acceptance tests** ต่อโมดูลโดยใช้รูปแบบ Given–When-Then ### **Testing Strategy โดยละเอียด** -* **Test Pyramid Structure** +- **Test Pyramid Structure** - /\ - / \ E2E Tests (10%) - /____\ Integration Tests (20%) - / \ Unit Tests (70%) -/________\ + /\ -* **Testing Tools Stack** + / \ E2E Tests (10%) + /\_**\_\ Integration Tests (20%) + / \ Unit Tests (70%) + /**\_\_****\ + +- **Testing Tools Stack** ```typescript // Backend Testing Stack @@ -111,7 +112,7 @@ const backendTesting = { integration: ['Supertest', 'Testcontainers', 'Jest'], e2e: ['Supertest', 'Jest', 'Database Seeds'], security: ['Jest', 'Custom Security Test Helpers'], - performance: ['Jest', 'autocannon', 'artillery'] + performance: ['Jest', 'autocannon', 'artillery'], }; // Frontend Testing Stack @@ -119,11 +120,11 @@ const frontendTesting = { unit: ['Vitest', 'React Testing Library'], integration: ['React Testing Library', 'MSW'], e2e: ['Playwright', 'Jest'], - visual: ['Playwright', 'Loki'] + visual: ['Playwright', 'Loki'], }; ``` -* **Test Data Management** +- **Test Data Management** ```typescript // Test Data Factories @@ -139,7 +140,7 @@ const testScenarios = { edgeCases: 'Boundary conditions and limits', errorConditions: 'Error handling and recovery', security: 'Authentication and authorization', - performance: 'Load and stress conditions' + performance: 'Load and stress conditions', }; ``` @@ -147,14 +148,14 @@ const testScenarios = { ### **3.1 หลักการ** -* **สถาปัตยกรรมแบบโมดูลาร์ (Modular architecture)**: - * หนึ่งโมดูลต่อหนึ่งโดเมน - * โครงสร้างแบบ Controller → Service → Repository (Model) -* API-First: มุ่งเน้นการสร้าง API ที่มีคุณภาพสูง มีเอกสารประกอบ (Swagger) ที่ชัดเจนสำหรับ Frontend Team -* DTOs ที่ตรวจสอบความถูกต้องด้วย **class-validator** -* ใช้ **MikroORM** (หรือ TypeORM/Prisma) สำหรับการคงอยู่ของข้อมูล (persistence) ซึ่งสอดคล้องกับสคีมา MariaDB -* ห่อหุ้มโค้ดที่ใช้ซ้ำได้ไว้ใน **common module** (@app/common): - * Configs, decorators, DTOs, guards, interceptors, notifications, shared services, types, validators +- **สถาปัตยกรรมแบบโมดูลาร์ (Modular architecture)**: + - หนึ่งโมดูลต่อหนึ่งโดเมน + - โครงสร้างแบบ Controller → Service → Repository (Model) +- API-First: มุ่งเน้นการสร้าง API ที่มีคุณภาพสูง มีเอกสารประกอบ (Swagger) ที่ชัดเจนสำหรับ Frontend Team +- DTOs ที่ตรวจสอบความถูกต้องด้วย **class-validator** +- ใช้ **MikroORM** (หรือ TypeORM/Prisma) สำหรับการคงอยู่ของข้อมูล (persistence) ซึ่งสอดคล้องกับสคีมา MariaDB +- ห่อหุ้มโค้ดที่ใช้ซ้ำได้ไว้ใน **common module** (@app/common): + - Configs, decorators, DTOs, guards, interceptors, notifications, shared services, types, validators ### **3.2 Database & Data Modeling (MariaDB + TypeORM)** @@ -194,13 +195,13 @@ CREATE INDEX idx_ref_project_id ON correspondence_revisions(ref_project_id); ปรับปรุง Service จัดการไฟล์ให้รองรับ Transactional Integrity 1. **Upload (Phase 1):** - * รับไฟล์ → Scan Virus (ClamAV) → Save ลงโฟลเดอร์ `temp/` - * Return `temp_id` และ Metadata กลับไปให้ Client + - รับไฟล์ → Scan Virus (ClamAV) → Save ลงโฟลเดอร์ `temp/` + - Return `temp_id` และ Metadata กลับไปให้ Client 2. **Commit (Phase 2):** - * เมื่อ Business Logic (เช่น Create Correspondence) ทำงานสำเร็จ - * Service จะย้ายไฟล์จาก `temp/` ไปยัง `permanent/{YYYY}/{MM}/` - * Update path ใน Database - * ทั้งหมดนี้ต้องอยู่ภายใต้ Database Transaction เดียวกัน (ถ้า DB Fail, ไฟล์จะค้างที่ Temp และถูกลบโดย Cron Job) + - เมื่อ Business Logic (เช่น Create Correspondence) ทำงานสำเร็จ + - Service จะย้ายไฟล์จาก `temp/` ไปยัง `permanent/{YYYY}/{MM}/` + - Update path ใน Database + - ทั้งหมดนี้ต้องอยู่ภายใต้ Database Transaction เดียวกัน (ถ้า DB Fail, ไฟล์จะค้างที่ Temp และถูกลบโดย Cron Job) ### **3.4 Document Numbering (Double-Lock Mechanism)** @@ -213,28 +214,28 @@ CREATE INDEX idx_ref_project_id ON correspondence_revisions(ref_project_id); ห้ามแยก Logic ระหว่าง `CorrespondenceRouting` และ `RfaWorkflow` ออกจากกันเด็ดขาด ให้สร้าง `WorkflowEngineService` ที่เป็น Generic: -* **Input:** `currentState`, `action`, `rules (Guard)` -* **Output:** `nextState`, `assignee` -* รองรับทั้ง Linear Flow (Routing) และ Complex Flow (RFA) ผ่าน Configuration +- **Input:** `currentState`, `action`, `rules (Guard)` +- **Output:** `nextState`, `assignee` +- รองรับทั้ง Linear Flow (Routing) และ Complex Flow (RFA) ผ่าน Configuration ### **3.6 ฟังก์ชันหลัก (Core Functionalities)** -* Global **filters** สำหรับการจัดการ exception -* **Middlewares** สำหรับการจัดการ request -* **Guards** สำหรับการอนุญาต (permissions) และ RBAC -* **Interceptors** สำหรับการแปลงข้อมูล response และการบันทึก log +- Global **filters** สำหรับการจัดการ exception +- **Middlewares** สำหรับการจัดการ request +- **Guards** สำหรับการอนุญาต (permissions) และ RBAC +- **Interceptors** สำหรับการแปลงข้อมูล response และการบันทึก log ### **3.7 ข้อจำกัดในการ Deploy (QNAP Container Station)** -* **ห้ามใช้ไฟล์ .env** ในการตั้งค่า Environment Variables [cite: 2.1] -* การตั้งค่าทั้งหมด (เช่น Database connection string, JWT secret) **จะต้องถูกกำหนดผ่าน Environment Variable ใน docker-compose.yml โดยตรง** [cite: 6.5] ซึ่งจะจัดการผ่าน UI ของ QNAP Container Station [cite: 2.1] +- **ห้ามใช้ไฟล์ .env** ในการตั้งค่า Environment Variables [cite: 2.1] +- การตั้งค่าทั้งหมด (เช่น Database connection string, JWT secret) **จะต้องถูกกำหนดผ่าน Environment Variable ใน docker-compose.yml โดยตรง** [cite: 6.5] ซึ่งจะจัดการผ่าน UI ของ QNAP Container Station [cite: 2.1] ### **3.8 ข้อจำกัดด้านความปลอดภัย (Security Constraints):** -* **File Upload Security:** ต้องมี virus scanning (ClamAV), file type validation (white-list), และ file size limits (50MB) -* **Input Validation:** ต้องป้องกัน OWASP Top 10 vulnerabilities (SQL Injection, XSS, CSRF) -* **Rate Limiting:** ต้อง implement rate limiting ตาม strategy ที่กำหนด -* **Secrets Management:** ต้องมี mechanism สำหรับจัดการ sensitive secrets อย่างปลอดภัย แม้จะใช้ docker-compose.yml +- **File Upload Security:** ต้องมี virus scanning (ClamAV), file type validation (white-list), และ file size limits (50MB) +- **Input Validation:** ต้องป้องกัน OWASP Top 10 vulnerabilities (SQL Injection, XSS, CSRF) +- **Rate Limiting:** ต้อง implement rate limiting ตาม strategy ที่กำหนด +- **Secrets Management:** ต้องมี mechanism สำหรับจัดการ sensitive secrets อย่างปลอดภัย แม้จะใช้ docker-compose.yml ### **3.9 โครงสร้างโมดูลตามโดเมน (Domain-Driven Module Structure)** @@ -242,125 +243,125 @@ CREATE INDEX idx_ref_project_id ON correspondence_revisions(ref_project_id); #### 3.9.1 **CommonModule:** -* เก็บ Services ที่ใช้ร่วมกัน เช่น DatabaseModule, FileStorageService (จัดการไฟล์ใน QNAP), AuditLogService, NotificationService -* จัดการ audit_logs -* NotificationService ต้องรองรับ Triggers ที่ระบุใน Requirement 6.7 [cite: 6.7] +- เก็บ Services ที่ใช้ร่วมกัน เช่น DatabaseModule, FileStorageService (จัดการไฟล์ใน QNAP), AuditLogService, NotificationService +- จัดการ audit_logs +- NotificationService ต้องรองรับ Triggers ที่ระบุใน Requirement 6.7 [cite: 6.7] #### 3.9.2 **AuthModule:** -* จัดการะการยืนยันตัวตน (JWT, Guards) -* **(สำคัญ)** ต้องรับผิดชอบการตรวจสอบสิทธิ์ **4 ระดับ** [cite: 4.2]: สิทธิ์ระดับระบบ (Global Role), สิทธิ์ระดับองกรณ์ (Organization Role), สิทธิ์ระดับโปรเจกต์ (Project Role), และ สิทธิ์ระดับสัญญา (Contract Role) -* **(สำคัญ)** ต้องมี API สำหรับ **Admin Panel** เพื่อ: - * สร้างและจัดการ Role และการจับคู่ Permission แบบไดนามิก [cite: 4.3] - * ให้ Superadmin สร้าง Organizations และกำหนด Org Admin ได้ [cite: 4.6] - * ให้ Superadmin/Admin จัดการ document_number_formats (รูปแบบเลขที่เอกสาร), document_number_counters (Running Number) [cite: 3.10] +- จัดการะการยืนยันตัวตน (JWT, Guards) +- **(สำคัญ)** ต้องรับผิดชอบการตรวจสอบสิทธิ์ **4 ระดับ** [cite: 4.2]: สิทธิ์ระดับระบบ (Global Role), สิทธิ์ระดับองกรณ์ (Organization Role), สิทธิ์ระดับโปรเจกต์ (Project Role), และ สิทธิ์ระดับสัญญา (Contract Role) +- **(สำคัญ)** ต้องมี API สำหรับ **Admin Panel** เพื่อ: + - สร้างและจัดการ Role และการจับคู่ Permission แบบไดนามิก [cite: 4.3] + - ให้ Superadmin สร้าง Organizations และกำหนด Org Admin ได้ [cite: 4.6] + - ให้ Superadmin/Admin จัดการ document_number_formats (รูปแบบเลขที่เอกสาร), document_number_counters (Running Number) [cite: 3.10] #### 3.9.3 **UserModule:** -* จัดการ users, roles, permissions, global_default_roles, role_permissions, user_roles, user_project_roles -* **(สำคัญ)** ต้องมี API สำหรับ **Admin Panel** เพื่อ: - * สร้างและจัดการ Role และการจับคู่ Permission แบบไดนามิก [cite: 4.3] +- จัดการ users, roles, permissions, global_default_roles, role_permissions, user_roles, user_project_roles +- **(สำคัญ)** ต้องมี API สำหรับ **Admin Panel** เพื่อ: + - สร้างและจัดการ Role และการจับคู่ Permission แบบไดนามิก [cite: 4.3] #### 3.9.4 **ProjectModule:** -* จัดการ projects, organizations, contracts, project_parties, contract_parties +- จัดการ projects, organizations, contracts, project_parties, contract_parties #### 3.9.5 **MasterModule:** -* จัดการ master data (correspondence_types, rfa_types, rfa_status_codes, rfa_approve_codes, circulation_status_codes, correspondence_types, correspondence_status, tags) [cite: 4.5] +- จัดการ master data (correspondence_types, rfa_types, rfa_status_codes, rfa_approve_codes, circulation_status_codes, correspondence_types, correspondence_status, tags) [cite: 4.5] #### 3.9.6 **CorrespondenceModule (โมดูลศูนย์กลาง):** -* จัดการ correspondences, correspondence_revisions, correspondence_tags -* **(สำคัญ)** Service นี้ต้อง Inject DocumentNumberingService เพื่อขอเลขที่เอกสารใหม่ก่อนการสร้าง -* **(สำคัญ)** ตรรกะการสร้าง/อัปเดต Revision จะอยู่ใน Service นี้ -* จัดการ correspondence_attachments (ตารางเชื่อมไฟล์แนบ) -* รับผิดชอบ Routing **Correspondence Routing** (correspondence_routings, correspondence_routing_template_steps, correspondence_routing_templates, correspondence_status_transitions) สำหรับการส่งต่อเอกสารทั่วไประหว่างองค์กร +- จัดการ correspondences, correspondence_revisions, correspondence_tags +- **(สำคัญ)** Service นี้ต้อง Inject DocumentNumberingService เพื่อขอเลขที่เอกสารใหม่ก่อนการสร้าง +- **(สำคัญ)** ตรรกะการสร้าง/อัปเดต Revision จะอยู่ใน Service นี้ +- จัดการ correspondence_attachments (ตารางเชื่อมไฟล์แนบ) +- รับผิดชอบ Routing **Correspondence Routing** (correspondence_routings, correspondence_routing_template_steps, correspondence_routing_templates, correspondence_status_transitions) สำหรับการส่งต่อเอกสารทั่วไประหว่างองค์กร #### 3.9.7 **RfaModule:** -* จัดการ rfas, rfa_revisions, rfa_items -* รับผิดชอบเวิร์กโฟลว์ **"RFA Workflows"** (rfa_workflows, rfa_workflow_templates, rfa_workflow_template_steps, rfa_status_transitions) สำหรับการอนุมัติเอกสารทางเทคนิค +- จัดการ rfas, rfa_revisions, rfa_items +- รับผิดชอบเวิร์กโฟลว์ **"RFA Workflows"** (rfa_workflows, rfa_workflow_templates, rfa_workflow_template_steps, rfa_status_transitions) สำหรับการอนุมัติเอกสารทางเทคนิค #### 3.9.8 **DrawingModule:** -* จัดการ shop_drawings, shop_drawing_revisions, contract_drawings, contract_drawing_volumes, contract_drawing_cats, contract_drawing_sub_cats, shop_drawing_main_categories, shop_drawing_sub_categories, contract_drawing_subcat_cat_maps, shop_drawing_revision_contract_refs -* จัดการ shop_drawing_revision_attachments และ contract_drawing_attachments(ตารางเชื่อมไฟล์แนบ) +- จัดการ shop_drawings, shop_drawing_revisions, contract_drawings, contract_drawing_volumes, contract_drawing_cats, contract_drawing_sub_cats, shop_drawing_main_categories, shop_drawing_sub_categories, contract_drawing_subcat_cat_maps, shop_drawing_revision_contract_refs +- จัดการ shop_drawing_revision_attachments และ contract_drawing_attachments(ตารางเชื่อมไฟล์แนบ) #### 3.9.9 **CirculationModule:** -* จัดการ circulations, circulation_templates, circulation_assignees -* จัดการ circulation_attachments (ตารางเชื่อมไฟล์แนบ) -* รับผิดชอบเวิร์กโฟลว์ **"Circulations"** (circulation_status_transitions, circulation_template_assignees, circulation_assignees, circulation_recipients, circulation_actions, circulation_action_documents)สำหรับการเวียนเอกสาร **ภายในองค์กร** +- จัดการ circulations, circulation_templates, circulation_assignees +- จัดการ circulation_attachments (ตารางเชื่อมไฟล์แนบ) +- รับผิดชอบเวิร์กโฟลว์ **"Circulations"** (circulation_status_transitions, circulation_template_assignees, circulation_assignees, circulation_recipients, circulation_actions, circulation_action_documents)สำหรับการเวียนเอกสาร **ภายในองค์กร** #### 3.9.10 **TransmittalModule:** -* จัดการ transmittals และ transmittal_items +- จัดการ transmittals และ transmittal_items #### 3.9.11 **SearchModule:** -* ให้บริการค้นหาขั้นสูง (Advanced Search) [cite: 6.2] โดยใช้ **Elasticsearch** เพื่อรองรับการค้นหาแบบ Full-text จากชื่อเรื่อง, รายละเอียด, เลขที่เอกสาร, ประเภท, วันที่, และ Tags -* ระบบจะใช้ Elasticsearch Engine ในการจัดทำดัชนีเพื่อการค้นหาข้อมูลเชิงลึกจากเนื้อหาของเอกสาร โดยข้อมูลจะถูกส่งไปทำดัชนีจาก Backend (NestJS) ทุกครั้งที่มีการสร้างหรือแก้ไขเอกสาร +- ให้บริการค้นหาขั้นสูง (Advanced Search) [cite: 6.2] โดยใช้ **Elasticsearch** เพื่อรองรับการค้นหาแบบ Full-text จากชื่อเรื่อง, รายละเอียด, เลขที่เอกสาร, ประเภท, วันที่, และ Tags +- ระบบจะใช้ Elasticsearch Engine ในการจัดทำดัชนีเพื่อการค้นหาข้อมูลเชิงลึกจากเนื้อหาของเอกสาร โดยข้อมูลจะถูกส่งไปทำดัชนีจาก Backend (NestJS) ทุกครั้งที่มีการสร้างหรือแก้ไขเอกสาร #### 3.9.12 **DocumentNumberingModule:** -* **สถานะ:** เป็น Module ภายใน (Internal Module) ไม่เปิด API สู่ภายนอก -* **หน้าที่:** ให้บริการ `DocumentNumberingService` แบบ **Token-Based Generator** -* **Logic ใหม่ (v1.4.4):** - * รับ Context: `{ projectId, orgId, typeId, disciplineId?, subTypeId?, year }` - * ดึง Template จาก DB - * Parse Template เพื่อหาว่าต้องใช้ Key ใดบ้างในการทำ Grouping Counter (เช่น ถ้า Template มี `{DISCIPLINE}` ให้ใช้ `discipline_id` ในการ query counter) - * ใช้ **Double-Lock Mechanism** (Redis + Optimistic DB Lock) ในการดึงและอัพเดทค่า `last_number` -* **Features:** - * Application-level locking เพื่อป้องกัน race condition - * Retry mechanism ด้วย exponential backoff - * Fallback mechanism เมื่อการขอเลขล้มเหลว - * Audit log ทุกครั้งที่มีการ generate เลขที่เอกสารใหม่ +- **สถานะ:** เป็น Module ภายใน (Internal Module) ไม่เปิด API สู่ภายนอก +- **หน้าที่:** ให้บริการ `DocumentNumberingService` แบบ **Token-Based Generator** +- **Logic ใหม่ (v1.4.4):** + - รับ Context: `{ projectId, orgId, typeId, disciplineId?, subTypeId?, year }` + - ดึง Template จาก DB + - Parse Template เพื่อหาว่าต้องใช้ Key ใดบ้างในการทำ Grouping Counter (เช่น ถ้า Template มี `{DISCIPLINE}` ให้ใช้ `discipline_id` ในการ query counter) + - ใช้ **Double-Lock Mechanism** (Redis + Optimistic DB Lock) ในการดึงและอัพเดทค่า `last_number` +- **Features:** + - Application-level locking เพื่อป้องกัน race condition + - Retry mechanism ด้วย exponential backoff + - Fallback mechanism เมื่อการขอเลขล้มเหลว + - Audit log ทุกครั้งที่มีการ generate เลขที่เอกสารใหม่ #### 3.9.13 **CorrespondenceRoutingModule:** -* **สถานะ:** โมดูลหลักสำหรับจัดการการส่งต่อเอกสาร -* **หน้าที่:** จัดการแม่แบบการส่งต่อและการส่งต่อจริง -* **Entities:** - * CorrespondenceRoutingTemplate - * CorrespondenceRoutingTemplateStep - * CorrespondenceRouting -* **Features:** - * สร้างและจัดการแม่แบบการส่งต่อ - * ดำเนินการส่งต่อเอกสารตามแม่แบบ - * ติดตามสถานะการส่งต่อ - * คำนวณวันครบกำหนดอัตโนมัติ - * ส่งการแจ้งเตือนเมื่อมีการส่งต่อใหม่ +- **สถานะ:** โมดูลหลักสำหรับจัดการการส่งต่อเอกสาร +- **หน้าที่:** จัดการแม่แบบการส่งต่อและการส่งต่อจริง +- **Entities:** + - CorrespondenceRoutingTemplate + - CorrespondenceRoutingTemplateStep + - CorrespondenceRouting +- **Features:** + - สร้างและจัดการแม่แบบการส่งต่อ + - ดำเนินการส่งต่อเอกสารตามแม่แบบ + - ติดตามสถานะการส่งต่อ + - คำนวณวันครบกำหนดอัตโนมัติ + - ส่งการแจ้งเตือนเมื่อมีการส่งต่อใหม่ #### 3.9.14 **WorkflowEngineModule:** -* **สถานะ:** Internal Module สำหรับจัดการ workflow logic -* **หน้าที่:** ประมวลผล state transitions และ business rules -* **Features:** - * State machine สำหรับสถานะเอกสาร - * Validation rules สำหรับการเปลี่ยนสถานะ - * Automatic status updates - * Deadline management และ escalation +- **สถานะ:** Internal Module สำหรับจัดการ workflow logic +- **หน้าที่:** ประมวลผล state transitions และ business rules +- **Features:** + - State machine สำหรับสถานะเอกสาร + - Validation rules สำหรับการเปลี่ยนสถานะ + - Automatic status updates + - Deadline management และ escalation #### 3.9.15 **JsonSchemaModule:** -* **สถานะ:** Internal Module สำหรับจัดการ JSON schemas -* **หน้าที่:** Validate, transform, และ manage JSON data structures -* **Features:** - * JSON schema validation ด้วย AJV - * Schema versioning และ migration - * Dynamic schema generation - * Data transformation และ sanitization +- **สถานะ:** Internal Module สำหรับจัดการ JSON schemas +- **หน้าที่:** Validate, transform, และ manage JSON data structures +- **Features:** + - JSON schema validation ด้วย AJV + - Schema versioning และ migration + - Dynamic schema generation + - Data transformation และ sanitization #### 3.9.16 **DetailsService:** -* **สถานะ:** Shared Service สำหรับจัดการ details fields -* **หน้าที่:** Centralized service สำหรับ JSON details operations -* **Methods:** - * validateDetails(type: string, data: any): ValidationResult - * transformDetails(input: any, targetVersion: string): any - * sanitizeDetails(data: any): any - * getDefaultDetails(type: string): any +- **สถานะ:** Shared Service สำหรับจัดการ details fields +- **หน้าที่:** Centralized service สำหรับ JSON details operations +- **Methods:** + - validateDetails(type: string, data: any): ValidationResult + - transformDetails(input: any, targetVersion: string): any + - sanitizeDetails(data: any): any + - getDefaultDetails(type: string): any ### **3.10 สถาปัตยกรรมระบบ (System Architecture)** @@ -397,151 +398,157 @@ CREATE INDEX idx_ref_project_id ON correspondence_revisions(ref_project_id); ### **3.11 กลยุทธ์ความทนทานและการจัดการข้อผิดพลาด (Resilience & Error Handling Strategy)** -* **Circuit Breaker Pattern:** ใช้สำหรับ external service calls (Email, LINE, Elasticsearch) -* **Retry Mechanism:** ด้วย exponential backoff สำหรับ transient failures -* **Fallback Strategies:** Graceful degradation เมื่อบริการภายนอกล้มเหลว -* **Error Handling:** Error messages ต้องไม่เปิดเผยข้อมูล sensitive -* **Monitoring:** Centralized error monitoring และ alerting system +- **Circuit Breaker Pattern:** ใช้สำหรับ external service calls (Email, LINE, Elasticsearch) +- **Retry Mechanism:** ด้วย exponential backoff สำหรับ transient failures +- **Fallback Strategies:** Graceful degradation เมื่อบริการภายนอกล้มเหลว +- **Error Handling:** Error messages ต้องไม่เปิดเผยข้อมูล sensitive +- **Monitoring:** Centralized error monitoring และ alerting system ### **3.12 FileStorageService (ปรับปรุงใหม่):** -* **Virus Scanning:** Integrate ClamAV สำหรับ scan ไฟล์ที่อัปโหลดทั้งหมด -* **File Type Validation:** ใช้ white-list approach (PDF, DWG, DOCX, XLSX, ZIP) -* **File Size Limits:** 50MB ต่อไฟล์ -* **Security Measures:** - * เก็บไฟล์นอก web root - * Download ผ่าน authenticated endpoint เท่านั้น - * Download links มี expiration time (24 ชั่วโมง) - * File integrity checks (checksum) - * Access control checks ก่อนดาวน์โหลด +- **Virus Scanning:** Integrate ClamAV สำหรับ scan ไฟล์ที่อัปโหลดทั้งหมด +- **File Type Validation:** ใช้ white-list approach (PDF, DWG, DOCX, XLSX, ZIP) +- **File Size Limits:** 50MB ต่อไฟล์ +- **Security Measures:** + - เก็บไฟล์นอก web root + - Download ผ่าน authenticated endpoint เท่านั้น + - Download links มี expiration time (24 ชั่วโมง) + - File integrity checks (checksum) + - Access control checks ก่อนดาวน์โหลด ### **3.13 เเทคโนโลยีที่ใช้ (Technology Stack)** -| ส่วน | Library/Tool | หมายเหตุ | -| ----------------------- | ---------------------------------------------------- | -------------------------------------- | -| **Framework** | `@nestjs/core`, `@nestjs/common` | Core Framework | -| **Language** | `TypeScript` | ใช้ TypeScript ทั้งระบบ | -| **Database** | `MariaDB 10.11` | ฐานข้อมูลหลัก | -| **ORM** | `@nestjs/typeorm`, `typeorm` | 🗃️จัดการการเชื่อมต่อและ Query ฐานข้อมูล | -| **Validation** | `class-validator`, `class-transformer` | 📦ตรวจสอบและแปลงข้อมูลใน DTO | -| **Auth** | `@nestjs/jwt`, `@nestjs/passport`, `passport-jwt` | 🔐การยืนยันตัวตนด้วย JWT | -| **Authorization** | `casl` | 🔐จัดการสิทธิ์แบบ RBAC | -| **File Upload** | `multer` | 📁จัดการการอัปโหลดไฟล์ | -| **Search** | `@nestjs/elasticsearch` | 🔍สำหรับการค้นหาขั้นสูง | -| **Notification** | `nodemailer` | 📬ส่งอีเมลแจ้งเตือน | +| ส่วน | Library/Tool | หมายเหตุ | +| ----------------------- | ---------------------------------------------------- | -------------------------------------------- | +| **Framework** | `@nestjs/core`, `@nestjs/common` | Core Framework | +| **Language** | `TypeScript` | ใช้ TypeScript ทั้งระบบ | +| **Database** | `MariaDB 10.11` | ฐานข้อมูลหลัก | +| **ORM** | `@nestjs/typeorm`, `typeorm` | 🗃️จัดการการเชื่อมต่อและ Query ฐานข้อมูล | +| **Validation** | `class-validator`, `class-transformer` | 📦ตรวจสอบและแปลงข้อมูลใน DTO | +| **Auth** | `@nestjs/jwt`, `@nestjs/passport`, `passport-jwt` | 🔐การยืนยันตัวตนด้วย JWT | +| **Authorization** | `casl` | 🔐จัดการสิทธิ์แบบ RBAC | +| **File Upload** | `multer` | 📁จัดการการอัปโหลดไฟล์ | +| **Search** | `@nestjs/elasticsearch` | 🔍สำหรับการค้นหาขั้นสูง | +| **Notification** | `nodemailer` | 📬ส่งอีเมลแจ้งเตือน | | **Scheduling** | `@nestjs/schedule` | 📬สำหรับ Cron Jobs (เช่น แจ้งเตือน Deadline) | -| **Logging** | `winston` | 📊บันทึก Log ที่มีประสิทธิภาพ | -| **Testing** | `@nestjs/testing`, `jest`, `supertest` | 🧪ทดสอบ Unit, Integration และ E2E | -| **Documentation** | `@nestjs/swagger` | 🌐สร้าง API Documentation อัตโนมัติ | -| **Security** | `helmet`, `rate-limiter-flexible` | 🛡️เพิ่มความปลอดภัยให้ API | -| **Resilience** | `@nestjs/circuit-breaker` | 🔄 Circuit breaker pattern | -| **Caching** | `@nestjs/cache-manager`, `cache-manager-redis-store` | 💾 Distributed caching | -| **Security** | `helmet`, `csurf`, `rate-limiter-flexible` | 🛡️ Security enhancements | -| **Validation** | `class-validator`, `class-transformer` | ✅ Input validation | -| **Monitoring** | `@nestjs/monitoring`, `winston` | 📊 Application monitoring | -| **File Processing** | `clamscan` | 🦠 Virus scanning | -| **Cryptography** | `bcrypt`, `crypto` | 🔐 Password hashing และ checksums | -| **JSON Validation** | `ajv`, `ajv-formats` | 🎯 JSON schema validation | -| **JSON Processing** | `jsonpath`, `json-schema-ref-parser` | 🔧 JSON manipulation | -| **Data Transformation** | `class-transformer` | 🔄 Object transformation | -| **Compression** | `compression` | 📦 JSON compression | +| **Logging** | `winston` | 📊บันทึก Log ที่มีประสิทธิภาพ | +| **Testing** | `@nestjs/testing`, `jest`, `supertest` | 🧪ทดสอบ Unit, Integration และ E2E | +| **Documentation** | `@nestjs/swagger` | 🌐สร้าง API Documentation อัตโนมัติ | +| **Security** | `helmet`, `rate-limiter-flexible` | 🛡️เพิ่มความปลอดภัยให้ API | +| **Resilience** | `@nestjs/circuit-breaker` | 🔄 Circuit breaker pattern | +| **Caching** | `@nestjs/cache-manager`, `cache-manager-redis-store` | 💾 Distributed caching | +| **Security** | `helmet`, `csurf`, `rate-limiter-flexible` | 🛡️ Security enhancements | +| **Validation** | `class-validator`, `class-transformer` | ✅ Input validation | +| **Monitoring** | `@nestjs/monitoring`, `winston` | 📊 Application monitoring | +| **File Processing** | `clamscan` | 🦠 Virus scanning | +| **Cryptography** | `bcrypt`, `crypto` | 🔐 Password hashing และ checksums | +| **JSON Validation** | `ajv`, `ajv-formats` | 🎯 JSON schema validation | +| **JSON Processing** | `jsonpath`, `json-schema-ref-parser` | 🔧 JSON manipulation | +| **Data Transformation** | `class-transformer` | 🔄 Object transformation | +| **Compression** | `compression` | 📦 JSON compression | ### **3.14 Security Testing:** -* **Penetration Testing:** ทดสอบ OWASP Top 10 vulnerabilities -* **Security Audit:** Review code สำหรับ security flaws -* **Virus Scanning Test:** ทดสอบ file upload security -* **Rate Limiting Test:** ทดสอบ rate limiting functionality +- **Penetration Testing:** ทดสอบ OWASP Top 10 vulnerabilities +- **Security Audit:** Review code สำหรับ security flaws +- **Virus Scanning Test:** ทดสอบ file upload security +- **Rate Limiting Test:** ทดสอบ rate limiting functionality ### **3.15 Performance Testing:** -* **Load Testing:** ทดสอบด้วย realistic workloads -* **Stress Testing:** หา breaking points ของระบบ -* **Endurance Testing:** ทดสอบการทำงานต่อเนื่องเป็นเวลานาน +- **Load Testing:** ทดสอบด้วย realistic workloads +- **Stress Testing:** หา breaking points ของระบบ +- **Endurance Testing:** ทดสอบการทำงานต่อเนื่องเป็นเวลานาน ### 🗄️**3.16 Backend State Management** Backend (NestJS) ควรเป็น **Stateless** (ไม่เก็บสถานะ) "State" ทั้งหมดจะถูกจัดเก็บใน MariaDB -* **Request-Scoped State (สถานะภายใน Request เดียว):** - * **ปัญหา:** จะส่งต่อข้อมูล (เช่น User ที่ล็อกอิน) ระหว่าง Guard และ Service ใน Request เดียวกันได้อย่างไร? - * **วิธีแก้:** ใช้ **Request-Scoped Providers** ของ NestJS (เช่น AuthContextService) เพื่อเก็บข้อมูล User ปัจจุบันที่ได้จาก AuthGuard และให้ Service อื่น Inject ไปใช้ -* **Application-Scoped State (การ Caching):** - * **ปัญหา:** ข้อมูล Master (เช่น roles, permissions, organizations) ถูกเรียกใช้บ่อย - * **วิธีแก้:** ใช้ **Caching** (เช่น @nestjs/cache-manager) เพื่อ Caching ข้อมูลเหล่านี้ และลดภาระ Database +- **Request-Scoped State (สถานะภายใน Request เดียว):** + - **ปัญหา:** จะส่งต่อข้อมูล (เช่น User ที่ล็อกอิน) ระหว่าง Guard และ Service ใน Request เดียวกันได้อย่างไร? + - **วิธีแก้:** ใช้ **Request-Scoped Providers** ของ NestJS (เช่น AuthContextService) เพื่อเก็บข้อมูล User ปัจจุบันที่ได้จาก AuthGuard และให้ Service อื่น Inject ไปใช้ +- **Application-Scoped State (การ Caching):** + - **ปัญหา:** ข้อมูล Master (เช่น roles, permissions, organizations) ถูกเรียกใช้บ่อย + - **วิธีแก้:** ใช้ **Caching** (เช่น @nestjs/cache-manager) เพื่อ Caching ข้อมูลเหล่านี้ และลดภาระ Database ### **3.17 Caching Strategy (ตามข้อ 6.4.2):** -* **Master Data Cache:** Roles, Permissions, Organizations (TTL: 1 hour) -* **User Session Cache:** User permissions และ profile (TTL: 30 minutes) -* **Search Result Cache:** Frequently searched queries (TTL: 15 minutes) -* **File Metadata Cache:** Attachment metadata (TTL: 1 hour) -* **Cache Invalidation:** Clear cache on update/delete operations +- **Master Data Cache:** Roles, Permissions, Organizations (TTL: 1 hour) +- **User Session Cache:** User permissions และ profile (TTL: 30 minutes) +- **Search Result Cache:** Frequently searched queries (TTL: 15 minutes) +- **File Metadata Cache:** Attachment metadata (TTL: 1 hour) +- **Cache Invalidation:** Clear cache on update/delete operations ### **3.18 การไหลของข้อมูล (Data Flow)** #### **3.18.1 Main Flow:** - 1. Request: ผ่าน Nginx Proxy Manager -> NestJS Controller - 2. **Rate Limiting:** RateLimitGuard ตรวจสอบ request limits - 3. **Input Validation:** Validation Pipe ตรวจสอบและ sanitize inputs - 4. Authentication: JWT Guard ตรวจสอบ Token และดึงข้อมูล User - 5. Authorization: RBAC Guard ตรวจสอบสิทธิ์ - 6. **Security Checks:** Virus scanning (สำหรับ file upload), XSS protection - 7. Business Logic: Service Layer ประมวลผลตรรกะทางธุรกิจ - 8. **Resilience:** Circuit breaker และ retry logic สำหรับ external calls - 9. Data Access: Repository Layer ติดต่อกับฐานข้อมูล - 10. **Caching:** Cache frequently accessed data - 11. **Audit Log:** บันทึกการกระทำสำคัญ - 12. Response: ส่งกลับไปยัง Frontend +1. Request: ผ่าน Nginx Proxy Manager -> NestJS Controller +2. **Rate Limiting:** RateLimitGuard ตรวจสอบ request limits +3. **Input Validation:** Validation Pipe ตรวจสอบและ sanitize inputs +4. Authentication: JWT Guard ตรวจสอบ Token และดึงข้อมูล User +5. Authorization: RBAC Guard ตรวจสอบสิทธิ์ +6. **Security Checks:** Virus scanning (สำหรับ file upload), XSS protection +7. Business Logic: Service Layer ประมวลผลตรรกะทางธุรกิจ +8. **Resilience:** Circuit breaker และ retry logic สำหรับ external calls +9. Data Access: Repository Layer ติดต่อกับฐานข้อมูล +10. **Caching:** Cache frequently accessed data +11. **Audit Log:** บันทึกการกระทำสำคัญ +12. Response: ส่งกลับไปยัง Frontend #### **3.18.2 Workflow Data Flow:** - 1. User สร้างเอกสาร → เลือก routing template - 2. System สร้าง routing instances ตาม template - 3. สำหรับแต่ละ routing step: +1. User สร้างเอกสาร → เลือก routing template +2. System สร้าง routing instances ตาม template +3. สำหรับแต่ละ routing step: + + - กำหนด due date (จาก expected_days) - ส่ง notification ไปยังองค์กรผู้รับ - อัพเดทสถานะเป็น SENT - 4. เมื่อองค์กรผู้รับดำเนินการ: + +4. เมื่อองค์กรผู้รับดำเนินการ: + + - อัพเดทสถานะเป็น ACTIONED/FORWARDED/REPLIED - บันทึก processed_by และ processed_at - ส่ง notification ไปยังขั้นตอนต่อไป (ถ้ามี) - 5. เมื่อครบทุกขั้นตอน → อัพเดทสถานะเอกสารเป็น COMPLETED + +5. เมื่อครบทุกขั้นตอน → อัพเดทสถานะเอกสารเป็น COMPLETED #### **3.18.3 JSON Details Processing Flow:** - 1. **Receive Request** → Get JSON data from client - 2. **Schema Validation** → Validate against predefined schema - 3. **Data Sanitization** → Sanitize and transform data - 4. **Version Check** → Handle schema version compatibility - 5. **Storage** → Store validated JSON in database - 6. **Retrieval** → Retrieve and transform on demand +1. **Receive Request** → Get JSON data from client +2. **Schema Validation** → Validate against predefined schema +3. **Data Sanitization** → Sanitize and transform data +4. **Version Check** → Handle schema version compatibility +5. **Storage** → Store validated JSON in database +6. **Retrieval** → Retrieve and transform on demand ### 📊**3.19 Monitoring & Observability (ตามข้อ 6.8)** #### **Application Monitoring:** -* **Health Checks:** `/health` endpoint สำหรับ load balancer -* **Metrics Collection:** Response times, error rates, throughput -* **Distributed Tracing:** สำหรับ request tracing across services -* **Log Aggregation:** Structured logging ด้วย JSON format -* **Alerting:** สำหรับ critical errors และ performance degradation +- **Health Checks:** `/health` endpoint สำหรับ load balancer +- **Metrics Collection:** Response times, error rates, throughput +- **Distributed Tracing:** สำหรับ request tracing across services +- **Log Aggregation:** Structured logging ด้วย JSON format +- **Alerting:** สำหรับ critical errors และ performance degradation #### **Business Metrics:** -* จำนวน documents created ต่อวัน -* Workflow completion rates -* User activity metrics -* System utilization rates -* Search query performance +- จำนวน documents created ต่อวัน +- Workflow completion rates +- User activity metrics +- System utilization rates +- Search query performance #### **Performance Targets:** -* API Response Time: < 200ms (90th percentile) -* Search Query Performance: < 500ms -* File Upload Performance: < 30 seconds สำหรับไฟล์ 50MB -* Cache Hit Ratio: > 80% +- API Response Time: < 200ms (90th percentile) +- Search Query Performance: < 500ms +- File Upload Performance: < 30 seconds สำหรับไฟล์ 50MB +- Cache Hit Ratio: > 80% ## 🖥️ **4. ฟรอนต์เอนด์ (Next.js) - Implementation Details** @@ -561,11 +568,12 @@ export const useDraftStore = create( (set) => ({ drafts: {}, saveDraft: (key, data) => set((state) => ({ drafts: { ...state.drafts, [key]: data } })), - clearDraft: (key) => set((state) => { - const newDrafts = { ...state.drafts }; - delete newDrafts[key]; - return { drafts: newDrafts }; - }), + clearDraft: (key) => + set((state) => { + const newDrafts = { ...state.drafts }; + delete newDrafts[key]; + return { drafts: newDrafts }; + }), }), { name: 'form-drafts' } ) @@ -576,15 +584,15 @@ export const useDraftStore = create( เพื่อรองรับ JSON Schema หลากหลายรูปแบบ ให้สร้าง Component กลางที่รับ Schema แล้ว Gen Form ออกมา (ลดการแก้ Code บ่อยๆ) -* **Libraries:** แนะนำ `react-jsonschema-form` หรือสร้าง Wrapper บน `react-hook-form` ที่ Recursively render field ตาม Type -* **Validation:** ใช้ `ajv` ที่ฝั่ง Client เพื่อ Validate JSON ก่อน Submit +- **Libraries:** แนะนำ `react-jsonschema-form` หรือสร้าง Wrapper บน `react-hook-form` ที่ Recursively render field ตาม Type +- **Validation:** ใช้ `ajv` ที่ฝั่ง Client เพื่อ Validate JSON ก่อน Submit ### **4.3 Mobile Responsiveness (Card View)** ตารางข้อมูล (`DataTable`) ต้องมีความฉลาดในการแสดงผล: -* **Desktop:** แสดงเป็น Table ปกติ -* **Mobile:** แปลงเป็น **Card View** โดยอัตโนมัติ (ซ่อน Header, แสดง Label คู่ Value ในแต่ละ Card) +- **Desktop:** แสดงเป็น Table ปกติ +- **Mobile:** แปลงเป็น **Card View** โดยอัตโนมัติ (ซ่อน Header, แสดง Label คู่ Value ในแต่ละ Card) ```tsx // components/ui/responsive-table.tsx @@ -606,79 +614,79 @@ export const useDraftStore = create( ### **4.5 แนวทางการพัฒนาโค้ด (Code Implementation Guidelines)** -* ใช้ **early returns** เพื่อความชัดเจน -* ใช้คลาสของ **TailwindCSS** ในการกำหนดสไตล์เสมอ -* ควรใช้ class: syntax แบบมีเงื่อนไข (หรือ utility clsx) มากกว่าการใช้ ternary operators ใน class strings -* ใช้ **const arrow functions** สำหรับ components และ handlers -* Event handlers ให้ขึ้นต้นด้วย handle... (เช่น handleClick, handleSubmit) -* รวมแอตทริบิวต์สำหรับการเข้าถึง (accessibility) ด้วย: +- ใช้ **early returns** เพื่อความชัดเจน +- ใช้คลาสของ **TailwindCSS** ในการกำหนดสไตล์เสมอ +- ควรใช้ class: syntax แบบมีเงื่อนไข (หรือ utility clsx) มากกว่าการใช้ ternary operators ใน class strings +- ใช้ **const arrow functions** สำหรับ components และ handlers +- Event handlers ให้ขึ้นต้นด้วย handle... (เช่น handleClick, handleSubmit) +- รวมแอตทริบิวต์สำหรับการเข้าถึง (accessibility) ด้วย: tabIndex="0", aria-label, onKeyDown, ฯลฯ -* ตรวจสอบให้แน่ใจว่าโค้ดทั้งหมด **สมบูรณ์**, **ผ่านการทดสอบ**, และ **ไม่ซ้ำซ้อน (DRY)** -* ต้อง import โมดูลที่จำเป็นต้องใช้อย่างชัดเจนเสมอ +- ตรวจสอบให้แน่ใจว่าโค้ดทั้งหมด **สมบูรณ์**, **ผ่านการทดสอบ**, และ **ไม่ซ้ำซ้อน (DRY)** +- ต้อง import โมดูลที่จำเป็นต้องใช้อย่างชัดเจนเสมอ ### **4.6 UI/UX ด้วย React** -* ใช้ **semantic HTML** -* ใช้คลาสของ **Tailwind** ที่รองรับ responsive (sm:, md:, lg:) -* รักษาลำดับชั้นของการมองเห็น (visual hierarchy) ด้วยการใช้ typography และ spacing -* ใช้ **Shadcn** components (Button, Input, Card, ฯลฯ) เพื่อ UI ที่สอดคล้องกัน -* ทำให้ components มีขนาดเล็กและมุ่งเน้นการทำงานเฉพาะอย่าง -* ใช้ utility classes สำหรับการจัดสไตล์อย่างรวดเร็ว (spacing, colors, text, ฯลฯ) -* ตรวจสอบให้แน่ใจว่าสอดคล้องกับ **ARIA** และใช้ semantic markup +- ใช้ **semantic HTML** +- ใช้คลาสของ **Tailwind** ที่รองรับ responsive (sm:, md:, lg:) +- รักษาลำดับชั้นของการมองเห็น (visual hierarchy) ด้วยการใช้ typography และ spacing +- ใช้ **Shadcn** components (Button, Input, Card, ฯลฯ) เพื่อ UI ที่สอดคล้องกัน +- ทำให้ components มีขนาดเล็กและมุ่งเน้นการทำงานเฉพาะอย่าง +- ใช้ utility classes สำหรับการจัดสไตล์อย่างรวดเร็ว (spacing, colors, text, ฯลฯ) +- ตรวจสอบให้แน่ใจว่าสอดคล้องกับ **ARIA** และใช้ semantic markup ### **4.7 การตรวจสอบฟอร์มและข้อผิดพลาด (Form Validation & Errors)** -* ใช้ไลบรารีฝั่ง client เช่น zod และ react-hook-form -* แสดงข้อผิดพลาดด้วย **alert components** หรือข้อความ inline -* ต้องมี labels, placeholders, และข้อความ feedback +- ใช้ไลบรารีฝั่ง client เช่น zod และ react-hook-form +- แสดงข้อผิดพลาดด้วย **alert components** หรือข้อความ inline +- ต้องมี labels, placeholders, และข้อความ feedback ### **🧪4.8 Frontend Testing** เราจะใช้ **React Testing Library (RTL)** สำหรับการทดสอบ Component และ **Playwright** สำหรับ E2E: -* **Unit Tests (การทดสอบหน่วยย่อย):** - * **เครื่องมือ:** Vitest + RTL - * **เป้าหมาย:** ทดสอบ Component ขนาดเล็ก (เช่น Buttons, Inputs) หรือ Utility functions -* **Integration Tests (การทดสอบการบูรณาการ):** - * **เครื่องมือ:** RTL + **Mock Service Worker (MSW)** - * **เป้าหมาย:** ทดสอบว่า Component หรือ Page ทำงานกับ API (ที่จำลองขึ้น) ได้ถูกต้อง - * **เทคนิค:** ใช้ MSW เพื่อจำลอง NestJS API และทดสอบว่า Component แสดงผลข้อมูลจำลองได้ถูกต้องหรือไม่ (เช่น ทดสอบหน้า Dashboard [cite: 5.3] ที่ดึงข้อมูลจาก v_user_tasks) -* **E2E (End-to-End) Tests:** - * **เครื่องมือ:** **Playwright** - * **เป้าหมาย:** ทดสอบ User Flow ทั้งระบบโดยอัตโนมัติ (เช่น ล็อกอิน -> สร้าง RFA -> ตรวจสอบ Workflow Visualization [cite: 5.6]) +- **Unit Tests (การทดสอบหน่วยย่อย):** + - **เครื่องมือ:** Vitest + RTL + - **เป้าหมาย:** ทดสอบ Component ขนาดเล็ก (เช่น Buttons, Inputs) หรือ Utility functions +- **Integration Tests (การทดสอบการบูรณาการ):** + - **เครื่องมือ:** RTL + **Mock Service Worker (MSW)** + - **เป้าหมาย:** ทดสอบว่า Component หรือ Page ทำงานกับ API (ที่จำลองขึ้น) ได้ถูกต้อง + - **เทคนิค:** ใช้ MSW เพื่อจำลอง NestJS API และทดสอบว่า Component แสดงผลข้อมูลจำลองได้ถูกต้องหรือไม่ (เช่น ทดสอบหน้า Dashboard [cite: 5.3] ที่ดึงข้อมูลจาก v_user_tasks) +- **E2E (End-to-End) Tests:** + - **เครื่องมือ:** **Playwright** + - **เป้าหมาย:** ทดสอบ User Flow ทั้งระบบโดยอัตโนมัติ (เช่น ล็อกอิน -> สร้าง RFA -> ตรวจสอบ Workflow Visualization [cite: 5.6]) ### **🗄️4.9 Frontend State Management** สำหรับ Next.js App Router เราจะแบ่ง State เป็น 4 ระดับ: 1. **Local UI State (สถานะ UI ชั่วคราว):** - * **เครื่องมือ:** useState, useReducer - * **ใช้เมื่อ:** จัดการสถานะเล็กๆ ที่จบใน Component เดียว (เช่น Modal เปิด/ปิด, ค่าใน Input) + - **เครื่องมือ:** useState, useReducer + - **ใช้เมื่อ:** จัดการสถานะเล็กๆ ที่จบใน Component เดียว (เช่น Modal เปิด/ปิด, ค่าใน Input) 2. **Server State (สถานะข้อมูลจากเซิร์ฟเวอร์):** - * **เครื่องมือ:** **React Query (TanStack Query)** หรือ SWR - * **ใช้เมื่อ:** จัดการข้อมูลที่ดึงมาจาก NestJS API (เช่น รายการ correspondences, rfas, drawings) - * **ทำไม:** React Query เป็น "Cache" ที่จัดการ Caching, Re-fetching, และ Invalidation ให้โดยอัตโนมัติ + - **เครื่องมือ:** **React Query (TanStack Query)** หรือ SWR + - **ใช้เมื่อ:** จัดการข้อมูลที่ดึงมาจาก NestJS API (เช่น รายการ correspondences, rfas, drawings) + - **ทำไม:** React Query เป็น "Cache" ที่จัดการ Caching, Re-fetching, และ Invalidation ให้โดยอัตโนมัติ 3. **Global Client State (สถานะส่วนกลางฝั่ง Client):** - * **เครื่องมือ:** **Zustand** (แนะนำ) หรือ Context API - * **ใช้เมื่อ:** จัดการข้อมูลที่ต้องใช้ร่วมกันทั่วทั้งแอป และ *ไม่ใช่* ข้อมูลจากเซิร์ฟเวอร์ (เช่น ข้อมูล User ที่ล็อกอิน, สิทธิ์ Permissions) + - **เครื่องมือ:** **Zustand** (แนะนำ) หรือ Context API + - **ใช้เมื่อ:** จัดการข้อมูลที่ต้องใช้ร่วมกันทั่วทั้งแอป และ _ไม่ใช่_ ข้อมูลจากเซิร์ฟเวอร์ (เช่น ข้อมูล User ที่ล็อกอิน, สิทธิ์ Permissions) 4. **Form State (สถานะของฟอร์ม):** - * **เครื่องมือ:** **React Hook Form** + **Zod** - * **ใช้เมื่อ:** จัดการฟอร์มที่ซับซ้อน (เช่น ฟอร์มสร้าง RFA, ฟอร์ม Circulation [cite: 3.7]) + - **เครื่องมือ:** **React Hook Form** + **Zod** + - **ใช้เมื่อ:** จัดการฟอร์มที่ซับซ้อน (เช่น ฟอร์มสร้าง RFA, ฟอร์ม Circulation [cite: 3.7]) ## 🔐 **5. Security & Access Control (Full Stack Integration)** ### **5.1 CASL Integration (Shared Ability)** -* **Backend:** ใช้ CASL กำหนด Permission Rule -* **Frontend:** ให้ดึง Rule (JSON) จาก Backend มา Load ใส่ `@casl/react` เพื่อให้ Logic การ Show/Hide ปุ่ม ตรงกัน 100% +- **Backend:** ใช้ CASL กำหนด Permission Rule +- **Frontend:** ให้ดึง Rule (JSON) จาก Backend มา Load ใส่ `@casl/react` เพื่อให้ Logic การ Show/Hide ปุ่ม ตรงกัน 100% ### **5.2 Maintenance Mode** เพิ่ม Middleware (ทั้ง NestJS และ Next.js) เพื่อตรวจสอบ Flag ใน Redis: -* ถ้า `MAINTENANCE_MODE = true` -* **API:** Return `503 Service Unavailable` (ยกเว้น Admin IP) -* **Frontend:** Redirect ไปหน้า `/maintenance` +- ถ้า `MAINTENANCE_MODE = true` +- **API:** Return `503 Service Unavailable` (ยกเว้น Admin IP) +- **Frontend:** Redirect ไปหน้า `/maintenance` ### **5.3 Idempotency Client** @@ -710,20 +718,20 @@ updateRFA(@Param('id') id: string) { #### **5.4.1 Roles (บทบาท)** -* **Superadmin**: ไม่มีข้อจำกัดใดๆ [cite: 4.3] -* **Admin**: มีสิทธิ์เต็มที่ในองค์กร [cite: 4.3] -* **Document Control**: เพิ่ม/แก้ไข/ลบ เอกสารในองค์กร [cite: 4.3] -* **Editor**: สามารถ เพิ่ม/แก้ไข เอกสารที่กำหนด [cite: 4.3] -* **Viewer**: สามารถดู เอกสาร [cite: 4.3] +- **Superadmin**: ไม่มีข้อจำกัดใดๆ [cite: 4.3] +- **Admin**: มีสิทธิ์เต็มที่ในองค์กร [cite: 4.3] +- **Document Control**: เพิ่ม/แก้ไข/ลบ เอกสารในองค์กร [cite: 4.3] +- **Editor**: สามารถ เพิ่ม/แก้ไข เอกสารที่กำหนด [cite: 4.3] +- **Viewer**: สามารถดู เอกสาร [cite: 4.3] #### **5.4.2 ตัวอย่าง Permissions (จากตาราง permissions)** -* rfas.view, rfas.create, rfas.respond, rfas.delete -* drawings.view, drawings.upload, drawings.delete -* corr.view, corr.manage -* transmittals.manage -* cirs.manage -* project_parties.manage +- rfas.view, rfas.create, rfas.respond, rfas.delete +- drawings.view, drawings.upload, drawings.delete +- corr.view, corr.manage +- transmittals.manage +- cirs.manage +- project_parties.manage การจับคู่ระหว่าง roles และ permissions **เริ่มต้น** จะถูก seed ผ่านสคริปต์ (ดังที่เห็นในไฟล์ SQL)**อย่างไรก็ตาม AuthModule/UserModule ต้องมี API สำหรับ Admin เพื่อสร้าง Role ใหม่และกำหนดสิทธิ์ (Permissions) เพิ่มเติมได้ในภายหลัง** [cite: 4.3] @@ -739,15 +747,15 @@ updateRFA(@Param('id') id: string) { ## 🔗 **7. แนวทางการบูรณาการ Full Stack (Full Stack Integration Guidelines)** -| Aspect (แง่มุม) | Backend (NestJS) | Frontend (NextJS) | UI Layer (Tailwind/Shadcn) | -| :----------------------- | :------------------------- | :---------------------------- | :------------------------------------- | -| API | REST / GraphQL Controllers | API hooks ผ่าน fetch/axios/SWR | Components ที่รับข้อมูล | -| Validation (การตรวจสอบ) | class-validator DTOs | zod / react-hook-form | สถานะของฟอร์ม/input ใน Shadcn | -| Auth (การยืนยันตัวตน) | Guards, JWT | NextAuth / cookies | สถานะ UI ของ Auth (loading, signed in) | -| Errors (ข้อผิดพลาด) | Global filters | Toasts / modals | Alerts / ข้อความ feedback | -| Testing (การทดสอบ) | Jest (unit/e2e) | Vitest / Playwright | Visual regression | -| Styles (สไตล์) | Scoped modules (ถ้าจำเป็น) | Tailwind / Shadcn | Tailwind utilities | -| Accessibility (การเข้าถึง) | Guards + filters | ARIA attributes | Semantic HTML | +| Aspect (แง่มุม) | Backend (NestJS) | Frontend (NextJS) | UI Layer (Tailwind/Shadcn) | +| :------------------------- | :------------------------- | :----------------------------- | :------------------------------------- | +| API | REST / GraphQL Controllers | API hooks ผ่าน fetch/axios/SWR | Components ที่รับข้อมูล | +| Validation (การตรวจสอบ) | class-validator DTOs | zod / react-hook-form | สถานะของฟอร์ม/input ใน Shadcn | +| Auth (การยืนยันตัวตน) | Guards, JWT | NextAuth / cookies | สถานะ UI ของ Auth (loading, signed in) | +| Errors (ข้อผิดพลาด) | Global filters | Toasts / modals | Alerts / ข้อความ feedback | +| Testing (การทดสอบ) | Jest (unit/e2e) | Vitest / Playwright | Visual regression | +| Styles (สไตล์) | Scoped modules (ถ้าจำเป็น) | Tailwind / Shadcn | Tailwind utilities | +| Accessibility (การเข้าถึง) | Guards + filters | ARIA attributes | Semantic HTML | ## 🗂️ **8. ข้อตกลงเฉพาะสำหรับ DMS (LCBP3-DMS)** @@ -757,33 +765,33 @@ updateRFA(@Param('id') id: string) { บันทึกการดำเนินการ CRUD และการจับคู่ทั้งหมดลงในตาราง audit_logs -| Field (ฟิลด์) | Type (จาก SQL) | Description (คำอธิบาย) | -| :----------- | :------------- | :----------------------------------------------- | -| audit_id | BIGINT | Primary Key | -| user_id | INT | ผู้ใช้ที่ดำเนินการ (FK -> users) | -| action | VARCHAR(100) | rfa.create, correspondence.update, login.success | -| entity_type | VARCHAR(50) | ชื่อตาราง/โมดูล เช่น 'rfa', 'correspondence' | -| entity_id | VARCHAR(50) | Primary ID ของระเบียนที่ได้รับผลกระทบ | -| details_json | JSON | ข้อมูลบริบท (เช่น ฟิลด์ที่มีการเปลี่ยนแปลง) | -| ip_address | VARCHAR(45) | IP address ของผู้ดำเนินการ | -| user_agent | VARCHAR(255) | User Agent ของผู้ดำเนินการ | -| created_at | TIMESTAMP | Timestamp (UTC) | +| Field (ฟิลด์) | Type (จาก SQL) | Description (คำอธิบาย) | +| :------------ | :------------- | :----------------------------------------------- | +| audit_id | BIGINT | Primary Key | +| user_id | INT | ผู้ใช้ที่ดำเนินการ (FK -> users) | +| action | VARCHAR(100) | rfa.create, correspondence.update, login.success | +| entity_type | VARCHAR(50) | ชื่อตาราง/โมดูล เช่น 'rfa', 'correspondence' | +| entity_id | VARCHAR(50) | Primary ID ของระเบียนที่ได้รับผลกระทบ | +| details_json | JSON | ข้อมูลบริบท (เช่น ฟิลด์ที่มีการเปลี่ยนแปลง) | +| ip_address | VARCHAR(45) | IP address ของผู้ดำเนินการ | +| user_agent | VARCHAR(255) | User Agent ของผู้ดำเนินการ | +| created_at | TIMESTAMP | Timestamp (UTC) | ### 📂**8.2 การจัดการไฟล์ (File Handling)** #### **8.2.1 มาตรฐานการอัปโหลดไฟล์ (File Upload Standard)** -* **Security-First Approach:** การอัปโหลดไฟล์ทั้งหมดจะถูกจัดการโดย FileStorageService ที่มี security measures ครบถ้วน -* ไฟล์จะถูกเชื่อมโยงไปยัง Entity ที่ถูกต้องผ่าน **ตารางเชื่อม (Junction Tables)** เท่านั้น: - * correspondence_attachments (เชื่อม Correspondence กับ Attachments) - * circulation_attachments (เชื่อม Circulation กับ Attachments) - * shop_drawing_revision_attachments (เชื่อม Shop Drawing Revision กับ Attachments) - * contract_drawing_attachments (เชื่อม Contract Drawing กับ Attachments) -* เส้นทางจัดเก็บไฟล์ (Upload path): อ้างอิงจาก Requirement 2.1 คือ /share/dms-data [cite: 2.1] โดย FileStorageService จะสร้างโฟลเดอร์ย่อยแบบรวมศูนย์ (เช่น /share/dms-data/uploads/{YYYY}/{MM}/[stored_filename]) -* ประเภทไฟล์ที่อนุญาต: pdf, dwg, docx, xlsx, zip (ผ่าน white-list validation) -* ขนาดสูงสุด: **50 MB** -* จัดเก็บนอก webroot -* ให้บริการไฟล์ผ่าน endpoint ที่ปลอดภัย /files/:attachment_id/download +- **Security-First Approach:** การอัปโหลดไฟล์ทั้งหมดจะถูกจัดการโดย FileStorageService ที่มี security measures ครบถ้วน +- ไฟล์จะถูกเชื่อมโยงไปยัง Entity ที่ถูกต้องผ่าน **ตารางเชื่อม (Junction Tables)** เท่านั้น: + - correspondence_attachments (เชื่อม Correspondence กับ Attachments) + - circulation_attachments (เชื่อม Circulation กับ Attachments) + - shop_drawing_revision_attachments (เชื่อม Shop Drawing Revision กับ Attachments) + - contract_drawing_attachments (เชื่อม Contract Drawing กับ Attachments) +- เส้นทางจัดเก็บไฟล์ (Upload path): อ้างอิงจาก Requirement 2.1 คือ /share/dms-data [cite: 2.1] โดย FileStorageService จะสร้างโฟลเดอร์ย่อยแบบรวมศูนย์ (เช่น /share/dms-data/uploads/{YYYY}/{MM}/[stored_filename]) +- ประเภทไฟล์ที่อนุญาต: pdf, dwg, docx, xlsx, zip (ผ่าน white-list validation) +- ขนาดสูงสุด: **50 MB** +- จัดเก็บนอก webroot +- ให้บริการไฟล์ผ่าน endpoint ที่ปลอดภัย /files/:attachment_id/download #### **8.2.2 Security Controls สำหรับ File Access:** @@ -797,17 +805,17 @@ updateRFA(@Param('id') id: string) { ### 🔟**8.3 การจัดการเลขที่เอกสาร (Document Numbering) [cite: 3.10]** -* **เป้าหมาย:** สร้างเลขที่เอกสาร (เช่น correspondence_number) โดยอัตโนมัติ ตามรูปแบบที่กำหนด -* **ตรรกะการนับ:** การนับ Running number (SEQ) จะนับแยกตาม Key: **Project + Originator Organization + Document Type + Year** -* **ตาราง SQL (Updated):** - * `document_number_formats`: เก็บ Template String (เช่น `{CONTRACT}-{TYPE}-{DISCIPLINE}-{SEQ:4}`) - * `document_number_counters`: **Primary Key เปลี่ยนเป็น Composite Key ใหม่:** `(project_id, originator_id, type_id, discipline_id, current_year)` เพื่อรองรับการรันเลขแยกตามสาขา -* **การทำงาน:** - * Service ต้องรองรับการ Resolve Token พิเศษ เช่น `{SUBTYPE_NUM}` ที่ต้องไป Join กับตาราง `correspondence_sub_types` - * DocumentNumberingModule จะให้บริการ DocumentNumberingService - * เมื่อ CorrespondenceModule ต้องการสร้างเอกสารใหม่, มันจะเรียก documentNumberingService.generateNextNumber(...) - * Service นี้จะใช้ **Redis distributed locking** แทน stored procedure ซึ่งจะจัดการ Database Transaction และ Row Locking ภายใน Application Layer เพื่อรับประกันการป้องกัน Race Condition - * มี retry mechanism และ fallback strategies +- **เป้าหมาย:** สร้างเลขที่เอกสาร (เช่น correspondence_number) โดยอัตโนมัติ ตามรูปแบบที่กำหนด +- **ตรรกะการนับ:** การนับ Running number (SEQ) จะนับแยกตาม Key: **Project + Originator Organization + Document Type + Year** +- **ตาราง SQL (Updated):** + - `document_number_formats`: เก็บ Template String (เช่น `{CONTRACT}-{TYPE}-{DISCIPLINE}-{SEQ:4}`) + - `document_number_counters`: **Primary Key เปลี่ยนเป็น Composite Key ใหม่:** `(project_id, originator_id, type_id, discipline_id, current_year)` เพื่อรองรับการรันเลขแยกตามสาขา +- **การทำงาน:** + - Service ต้องรองรับการ Resolve Token พิเศษ เช่น `{SUBTYPE_NUM}` ที่ต้องไป Join กับตาราง `correspondence_sub_types` + - DocumentNumberingModule จะให้บริการ DocumentNumberingService + - เมื่อ CorrespondenceModule ต้องการสร้างเอกสารใหม่, มันจะเรียก documentNumberingService.generateNextNumber(...) + - Service นี้จะใช้ **Redis distributed locking** แทน stored procedure ซึ่งจะจัดการ Database Transaction และ Row Locking ภายใน Application Layer เพื่อรับประกันการป้องกัน Race Condition + - มี retry mechanism และ fallback strategies ### 📊**8.4 การรายงานและการส่งออก (Reporting & Exports)** @@ -815,114 +823,114 @@ updateRFA(@Param('id') id: string) { การรายงานควรสร้างขึ้นจาก Views ที่กำหนดไว้ล่วงหน้าในฐานข้อมูลเป็นหลัก: -* v_current_correspondences: สำหรับ revision ปัจจุบันทั้งหมดของเอกสารที่ไม่ใช่ RFA -* v_current_rfas: สำหรับ revision ปัจจุบันทั้งหมดของ RFA และข้อมูล master -* v_contract_parties_all: สำหรับการตรวจสอบความสัมพันธ์ของ project/contract/organization -* v_user_tasks: สำหรับ Dashboard "งานของฉัน" -* v_audit_log_details: สำหรับ Activity Feed +- v_current_correspondences: สำหรับ revision ปัจจุบันทั้งหมดของเอกสารที่ไม่ใช่ RFA +- v_current_rfas: สำหรับ revision ปัจจุบันทั้งหมดของ RFA และข้อมูล master +- v_contract_parties_all: สำหรับการตรวจสอบความสัมพันธ์ของ project/contract/organization +- v_user_tasks: สำหรับ Dashboard "งานของฉัน" +- v_audit_log_details: สำหรับ Activity Feed Views เหล่านี้ทำหน้าที่เป็นแหล่งข้อมูลหลักสำหรับการรายงานฝั่งเซิร์ฟเวอร์และการส่งออกข้อมูล #### **8.4.2 กฎการส่งออก (Export Rules)** -* Export formats: CSV, Excel, PDF. -* จัดเตรียมมุมมองสำหรับพิมพ์ (Print view). -* รวมลิงก์ไปยังต้นทาง (เช่น /rfas/:id). +- Export formats: CSV, Excel, PDF. +- จัดเตรียมมุมมองสำหรับพิมพ์ (Print view). +- รวมลิงก์ไปยังต้นทาง (เช่น /rfas/:id). ## 🧮 **9. ฟรอนต์เอนด์: รูปแบบ DataTable และฟอร์ม (Frontend: DataTable & Form Patterns)** ### **9.1 DataTable (Server‑Side)** -* Endpoint: /api/{module}?page=1&pageSize=20&sort=...&filter=... -* ต้องรองรับ: การแบ่งหน้า (pagination), การเรียงลำดับ (sorting), การค้นหา (search), การกรอง (filters) -* แสดง revision ล่าสุดแบบ inline เสมอ (สำหรับ RFA/Drawing) +- Endpoint: /api/{module}?page=1&pageSize=20&sort=...&filter=... +- ต้องรองรับ: การแบ่งหน้า (pagination), การเรียงลำดับ (sorting), การค้นหา (search), การกรอง (filters) +- แสดง revision ล่าสุดแบบ inline เสมอ (สำหรับ RFA/Drawing) ### **9.2 มาตรฐานฟอร์ม (Form Standards)** -* ต้องมีการใช้งาน Dropdowns แบบขึ้นต่อกัน (Dependent dropdowns) (ตามที่สคีมารองรับ): - * Project → Contract Drawing Volumes - * Contract Drawing Category → Sub-Category - * RFA (ประเภท Shop Drawing) → Shop Drawing Revisions ที่เชื่อมโยงได้ -* **File Upload Security:** ต้องรองรับ **Multi-file upload (Drag-and-Drop)** [cite: 5.7] พร้อม virus scanning feedback -* **File Type Indicators:** UI ต้องอนุญาตให้ผู้ใช้กำหนดว่าไฟล์ใดเป็น **"เอกสารหลัก"** หรือ "เอกสารแนบประกอบ" [cite: 5.7] พร้อมแสดง file type icons -* **Security Feedback:** แสดง security warnings สำหรับ file types ที่เสี่ยงหรือ files ที่ fail virus scan -* ส่ง (Submit) ผ่าน API พร้อม feedback แบบ toast +- ต้องมีการใช้งาน Dropdowns แบบขึ้นต่อกัน (Dependent dropdowns) (ตามที่สคีมารองรับ): + - Project → Contract Drawing Volumes + - Contract Drawing Category → Sub-Category + - RFA (ประเภท Shop Drawing) → Shop Drawing Revisions ที่เชื่อมโยงได้ +- **File Upload Security:** ต้องรองรับ **Multi-file upload (Drag-and-Drop)** [cite: 5.7] พร้อม virus scanning feedback +- **File Type Indicators:** UI ต้องอนุญาตให้ผู้ใช้กำหนดว่าไฟล์ใดเป็น **"เอกสารหลัก"** หรือ "เอกสารแนบประกอบ" [cite: 5.7] พร้อมแสดง file type icons +- **Security Feedback:** แสดง security warnings สำหรับ file types ที่เสี่ยงหรือ files ที่ fail virus scan +- ส่ง (Submit) ผ่าน API พร้อม feedback แบบ toast ### **9.3 ข้อกำหนด Component เฉพาะ (Specific UI Requirements)** -* **Dashboard - My Tasks:** ต้องพัฒนา Component ตาราง "งานของฉัน" (My Tasks)ซึ่งดึงข้อมูลงานที่ผู้ใช้ล็อกอินอยู่ต้องรับผิดชอบ (Main/Action) จาก v_user_tasks [cite: 5.3] -* **Workflow Visualization:** ต้องพัฒนา Component สำหรับแสดงผล Workflow (โดยเฉพาะ RFA)ที่แสดงขั้นตอนทั้งหมดเป็นลำดับ โดยขั้นตอนปัจจุบัน (active) เท่านั้นที่ดำเนินการได้ และขั้นตอนอื่นเป็น disabled [cite: 5.6] ต้องมีตรรกะสำหรับ Admin ในการ override หรือย้อนกลับขั้นตอนได้ [cite: 5.6] -* **Admin Panel:** ต้องมีหน้า UI สำหรับ Superadmin/Admin เพื่อจัดการข้อมูลหลัก (Master Data [cite: 4.5]), การเริ่มต้นใช้งาน (Onboarding [cite: 4.6]), และ **รูปแบบเลขที่เอกสาร (Numbering Formats [cite: 3.10])** -* **Security Dashboard:** แสดง security metrics และ audit logs สำหรับ administrators +- **Dashboard - My Tasks:** ต้องพัฒนา Component ตาราง "งานของฉัน" (My Tasks)ซึ่งดึงข้อมูลงานที่ผู้ใช้ล็อกอินอยู่ต้องรับผิดชอบ (Main/Action) จาก v_user_tasks [cite: 5.3] +- **Workflow Visualization:** ต้องพัฒนา Component สำหรับแสดงผล Workflow (โดยเฉพาะ RFA)ที่แสดงขั้นตอนทั้งหมดเป็นลำดับ โดยขั้นตอนปัจจุบัน (active) เท่านั้นที่ดำเนินการได้ และขั้นตอนอื่นเป็น disabled [cite: 5.6] ต้องมีตรรกะสำหรับ Admin ในการ override หรือย้อนกลับขั้นตอนได้ [cite: 5.6] +- **Admin Panel:** ต้องมีหน้า UI สำหรับ Superadmin/Admin เพื่อจัดการข้อมูลหลัก (Master Data [cite: 4.5]), การเริ่มต้นใช้งาน (Onboarding [cite: 4.6]), และ **รูปแบบเลขที่เอกสาร (Numbering Formats [cite: 3.10])** +- **Security Dashboard:** แสดง security metrics และ audit logs สำหรับ administrators ## 🧭 **10. แดชบอร์ดและฟีดกิจกรรม (Dashboard & Activity Feed)** ### **10.1 การ์ดบนแดชบอร์ด (Dashboard Cards)** -* แสดง Correspondences, RFAs, Circulations, Shop Drawing Revision ล่าสุด -* รวมสรุป KPI (เช่น "RFAs ที่รอการอนุมัติ", "Shop Drawing ที่รอการอนุมัติ") [cite: 5.3] -* รวมลิงก์ด่วนไปยังโมดูลต่างๆ -* **Security Metrics:** แสดงจำนวน files scanned, security incidents, failed login attempts +- แสดง Correspondences, RFAs, Circulations, Shop Drawing Revision ล่าสุด +- รวมสรุป KPI (เช่น "RFAs ที่รอการอนุมัติ", "Shop Drawing ที่รอการอนุมัติ") [cite: 5.3] +- รวมลิงก์ด่วนไปยังโมดูลต่างๆ +- **Security Metrics:** แสดงจำนวน files scanned, security incidents, failed login attempts ### **10.2 ฟีดกิจกรรม (Activity Feed)** -* แสดงรายการ v_audit_log_details ล่าสุด (10 รายการ) ที่เกี่ยวข้องกับผู้ใช้ -* รวม security-related activities (failed logins, permission changes) +- แสดงรายการ v_audit_log_details ล่าสุด (10 รายการ) ที่เกี่ยวข้องกับผู้ใช้ +- รวม security-related activities (failed logins, permission changes) ```typescript // ตัวอย่าง API response [ { user: 'editor01', action: 'Updated RFA (LCBP3-RFA-001)', time: '2025-11-04T09:30Z' }, - { user: 'system', action: 'Virus scan completed - 0 threats found', time: '2025-11-04T09:25Z' } -] + { user: 'system', action: 'Virus scan completed - 0 threats found', time: '2025-11-04T09:25Z' }, +]; ``` ## 🛡️ **11. ข้อกำหนดที่ไม่ใช่ฟังก์ชันการทำงาน (Non-Functional Requirements)** ส่วนนี้สรุปข้อกำหนด Non-Functional จาก requirements.md เพื่อให้ทีมพัฒนาทาน -* **Audit Log [cite: 6.1]:** ทุกการกระทำที่สำคัญ (C/U/D) ต้องถูกบันทึกใน audit_logs -* **Performance [cite: 6.4]:** ต้องใช้ Caching สำหรับข้อมูลที่เรียกบ่อย และใช้ Pagination -* **Security [cite: 6.5]:** ต้องมี Rate Limiting และจัดการ Secret ผ่าน docker-compose.yml (ไม่ใช่ .env) -* **File Security [cite: 3.9.6]:** ต้องมี virus scanning, file type validation, access controls -* **Resilience [cite: 6.5.3]:** ต้องมี circuit breaker, retry mechanisms, graceful degradation -* **Backup & Recovery [cite: 6.6]:** ต้องมีแผนสำรองข้อมูลทั้ง Database (MariaDB) และ File Storage (/share/dms-data) อย่างน้อยวันละ 1 ครั้ง -* **Notification Strategy [cite: 6.7]:** ระบบแจ้งเตือน (Email/Line) ต้องถูก Trigger เมื่อมีเอกสารใหม่ส่งถึง, มีการมอบหมายงานใหม่ (Circulation), หรือ (ทางเลือก) เมื่องานเสร็จ/ใกล้ถึงกำหนด -* **Monitoring [cite: 6.8]:** ต้องมี health checks, metrics collection, alerting +- **Audit Log [cite: 6.1]:** ทุกการกระทำที่สำคัญ (C/U/D) ต้องถูกบันทึกใน audit_logs +- **Performance [cite: 6.4]:** ต้องใช้ Caching สำหรับข้อมูลที่เรียกบ่อย และใช้ Pagination +- **Security [cite: 6.5]:** ต้องมี Rate Limiting และจัดการ Secret ผ่าน docker-compose.yml (ไม่ใช่ .env) +- **File Security [cite: 3.9.6]:** ต้องมี virus scanning, file type validation, access controls +- **Resilience [cite: 6.5.3]:** ต้องมี circuit breaker, retry mechanisms, graceful degradation +- **Backup & Recovery [cite: 6.6]:** ต้องมีแผนสำรองข้อมูลทั้ง Database (MariaDB) และ File Storage (/share/dms-data) อย่างน้อยวันละ 1 ครั้ง +- **Notification Strategy [cite: 6.7]:** ระบบแจ้งเตือน (Email/Line) ต้องถูก Trigger เมื่อมีเอกสารใหม่ส่งถึง, มีการมอบหมายงานใหม่ (Circulation), หรือ (ทางเลือก) เมื่องานเสร็จ/ใกล้ถึงกำหนด +- **Monitoring [cite: 6.8]:** ต้องมี health checks, metrics collection, alerting ## ✅ **12. มาตรฐานที่นำไปใช้แล้ว (จาก SQL v1.4.0) (Implemented Standards (from SQL v1.4.0))** ส่วนนี้ยืนยันว่าแนวทางปฏิบัติที่ดีที่สุดต่อไปนี้เป็นส่วนหนึ่งของการออกแบบฐานข้อมูลอยู่แล้ว และควรถูกนำไปใช้ประโยชน์ ไม่ใช่สร้างขึ้นใหม่ -* ✅ **Soft Delete:** นำไปใช้แล้วผ่านคอลัมน์ deleted_at ในตารางสำคัญ (เช่น correspondences, rfas, project_parties) ตรรกะการดึงข้อมูลต้องกรอง deleted_at IS NULL -* ✅ **Database Indexes:** สคีมาได้มีการทำ index ไว้อย่างหนักหน่วงบน foreign keys และคอลัมน์ที่ใช้ค้นหาบ่อย (เช่น idx_rr_rfa, idx_cor_project, idx_cr_is_current) เพื่อประสิทธิภาพ -* ✅ **โครงสร้าง RBAC:** มีระบบ users, roles, permissions, user_roles, และ user_project_roles ที่ครอบคลุมอยู่แล้ว -* ✅ **Data Seeding:** ข้อมูล Master (roles, permissions, organization_roles, initial users, project parties) ถูกรวมอยู่ในสคริปต์สคีมาแล้ว -* ✅ **Application-level Locking:** ใช้ Redis distributed lock แทน stored procedure -* ✅ **File Security:** Virus scanning, file type validation, access control -* ✅ **Resilience Patterns:** Circuit breaker, retry, fallback mechanisms -* ✅ **Security Measures:** Input validation, rate limiting, security headers -* ✅ **Monitoring:** Health checks, metrics collection, distributed tracing +- ✅ **Soft Delete:** นำไปใช้แล้วผ่านคอลัมน์ deleted_at ในตารางสำคัญ (เช่น correspondences, rfas, project_parties) ตรรกะการดึงข้อมูลต้องกรอง deleted_at IS NULL +- ✅ **Database Indexes:** สคีมาได้มีการทำ index ไว้อย่างหนักหน่วงบน foreign keys และคอลัมน์ที่ใช้ค้นหาบ่อย (เช่น idx_rr_rfa, idx_cor_project, idx_cr_is_current) เพื่อประสิทธิภาพ +- ✅ **โครงสร้าง RBAC:** มีระบบ users, roles, permissions, user_roles, และ user_project_roles ที่ครอบคลุมอยู่แล้ว +- ✅ **Data Seeding:** ข้อมูล Master (roles, permissions, organization_roles, initial users, project parties) ถูกรวมอยู่ในสคริปต์สคีมาแล้ว +- ✅ **Application-level Locking:** ใช้ Redis distributed lock แทน stored procedure +- ✅ **File Security:** Virus scanning, file type validation, access control +- ✅ **Resilience Patterns:** Circuit breaker, retry, fallback mechanisms +- ✅ **Security Measures:** Input validation, rate limiting, security headers +- ✅ **Monitoring:** Health checks, metrics collection, distributed tracing ## 🧩 **13. การปรับปรุงที่แนะนำ (สำหรับอนาคต) (Recommended Enhancements (Future))** -* ✅ สร้าง Background job (โดยใช้ **n8n** เพื่อเชื่อมต่อกับ **Line** [cite: 2.7] และ/หรือใช้สำหรับการแจ้งเตือน RFA ที่ใกล้ถึงกำหนด due_date [cite: 6.7]) -* ✅ เพิ่ม job ล้างข้อมูลเป็นระยะสำหรับ attachments ที่ไม่ถูกเชื่อมโยงกับ Entity ใดๆ เลย (ไฟล์กำพร้า) -* 🔄 **AI-Powered Document Classification:** ใช้ machine learning สำหรับ automatic document categorization -* 🔄 **Advanced Analytics:** Predictive analytics สำหรับ workflow optimization -* 🔄 **Mobile App:** Native mobile application สำหรับ field workers -* 🔄 **Blockchain Integration:** สำหรับ document integrity verification ที่ต้องการความปลอดภัยสูงสุด +- ✅ สร้าง Background job (โดยใช้ **n8n** เพื่อเชื่อมต่อกับ **Line** [cite: 2.7] และ/หรือใช้สำหรับการแจ้งเตือน RFA ที่ใกล้ถึงกำหนด due_date [cite: 6.7]) +- ✅ เพิ่ม job ล้างข้อมูลเป็นระยะสำหรับ attachments ที่ไม่ถูกเชื่อมโยงกับ Entity ใดๆ เลย (ไฟล์กำพร้า) +- 🔄 **AI-Powered Document Classification:** ใช้ machine learning สำหรับ automatic document categorization +- 🔄 **Advanced Analytics:** Predictive analytics สำหรับ workflow optimization +- 🔄 **Mobile App:** Native mobile application สำหรับ field workers +- 🔄 **Blockchain Integration:** สำหรับ document integrity verification ที่ต้องการความปลอดภัยสูงสุด ## ✅ **14. Summary Checklist for Developers** ก่อนส่ง PR (Pull Request) นักพัฒนาต้องตรวจสอบหัวข้อต่อไปนี้: -* [ ] **Security:** ไม่มี Secrets ใน Code, ใช้ `docker-compose.override.yml` แล้ว -* [ ] **Concurrency:** ใช้ Optimistic Lock ใน Entity ที่เสี่ยง Race Condition แล้ว -* [ ] **Idempotency:** API รองรับ Idempotency Key แล้ว -* [ ] **File Upload:** ใช้ Flow Two-Phase (Temp -> Perm) แล้ว -* [ ] **Mobile:** หน้าจอแสดงผลแบบ Card View บนมือถือได้ถูกต้อง -* [ ] **Performance:** สร้าง Index สำหรับ JSON Virtual Columns แล้ว (ถ้ามี) +- [ ] **Security:** ไม่มี Secrets ใน Code, ใช้ `docker-compose.override.yml` แล้ว +- [ ] **Concurrency:** ใช้ Optimistic Lock ใน Entity ที่เสี่ยง Race Condition แล้ว +- [ ] **Idempotency:** API รองรับ Idempotency Key แล้ว +- [ ] **File Upload:** ใช้ Flow Two-Phase (Temp -> Perm) แล้ว +- [ ] **Mobile:** หน้าจอแสดงผลแบบ Card View บนมือถือได้ถูกต้อง +- [ ] **Performance:** สร้าง Index สำหรับ JSON Virtual Columns แล้ว (ถ้ามี) --- @@ -962,13 +970,13 @@ Views เหล่านี้ทำหน้าที่เป็นแหล ## **Document Control:** -* **Document:** FullStackJS v1.4.4 -* **Version:** 1.4 -* **Date:** 2025-11-26 -* **Author:** NAP LCBP3-DMS & Gemini -* **Status:** FINAL-Rev.04 -* **Classification:** Internal Technical Documentation -* **Approved By:** Nattanin +- **Document:** FullStackJS v1.4.4 +- **Version:** 1.4 +- **Date:** 2025-11-26 +- **Author:** NAP LCBP3-DMS & Gemini +- **Status:** FINAL-Rev.04 +- **Classification:** Internal Technical Documentation +- **Approved By:** Nattanin --- diff --git a/specs/99-archives/docs/Markdown/2_Backend_Plan_Phase6A_V1_4_3.md b/specs/99-archives/docs/Markdown/2_Backend_Plan_Phase6A_V1_4_3.md index 6e75a61..3be78b0 100644 --- a/specs/99-archives/docs/Markdown/2_Backend_Plan_Phase6A_V1_4_3.md +++ b/specs/99-archives/docs/Markdown/2_Backend_Plan_Phase6A_V1_4_3.md @@ -1,4 +1,5 @@ -# “Phase 6A + Technical Design Document : Workflow DSL (Mini-Language)”** +# “Phase 6A + Technical Design Document : Workflow DSL (Mini-Language)”\*\* + ออกแบบสำหรับระบบ Workflow Engine กลางของโครงการ **ไม่มีโค้ดผูกกับ Framework** เพื่อให้สามารถนำไป Implement ใน NestJS หรือ Microservice ใด ๆ ได้ @@ -10,20 +11,19 @@ ใน Phase นี้ จะเริ่มสร้าง “Workflow DSL (Domain-Specific Language)” สำหรับนิยามกฎการเดินงาน (Workflow Transition Rules) ให้สามารถ: -* แยก **Business Workflow Logic** ออกจาก Source Code -* แก้ไขกฎ Workflow ได้โดย **ไม่ต้องแก้โค้ดและไม่ต้อง Deploy ใหม่** -* รองรับ Document หลายประเภท เช่น +- แยก **Business Workflow Logic** ออกจาก Source Code +- แก้ไขกฎ Workflow ได้โดย **ไม่ต้องแก้โค้ดและไม่ต้อง Deploy ใหม่** +- รองรับ Document หลายประเภท เช่น + - Correspondence + - RFA + - Internal Circulation + - Document Transmittal - * Correspondence - * RFA - * Internal Circulation - * Document Transmittal -* รองรับ Multi-step routing, skip, reject, rollback, parallel assignments -* สามารถนำไปใช้งานทั้งใน - - * Backend (NestJS) - * Frontend (UI Driven) - * External Microservices +- รองรับ Multi-step routing, skip, reject, rollback, parallel assignments +- สามารถนำไปใช้งานทั้งใน + - Backend (NestJS) + - Frontend (UI Driven) + - External Microservices --- @@ -35,12 +35,12 @@ ### 🧩 Output ของ Phase 6A -* DSL Specification (Grammar) -* JSON Schema for Workflow Definition -* Workflow Rule Interpreter (Parser + Executor) -* Validation Engine (Compile-time and Runtime) -* Storage (DB Table / Registry) -* Execution API: +- DSL Specification (Grammar) +- JSON Schema for Workflow Definition +- Workflow Rule Interpreter (Parser + Executor) +- Validation Engine (Compile-time and Runtime) +- Storage (DB Table / Registry) +- Execution API: | Action | Description | | -------------------------------- | ------------------------------- | @@ -59,22 +59,22 @@ #### Functional Requirements -* นิยาม Workflow เป็นภาษาคล้าย State Machine -* แต่ละเอกสารมี **State, Actions, Entry/Exit Events** -* สามารถมี: - - * Required approvals - * Conditional transition - * Auto-transition - * Parallel approval - * Return/rollback +- นิยาม Workflow เป็นภาษาคล้าย State Machine +- แต่ละเอกสารมี **State, Actions, Entry/Exit Events** +- สามารถมี: + - Required approvals + - Conditional transition + - Auto-transition + - Parallel approval + - Return/rollback #### -* Running time: < 20ms ต่อคำสั่ง -* Hot reload ไม่ต้อง Compile ใหม่ทั้ง Backend -* DSL ต้อง Debug ได้ง่าย -* ต้อง Versioned -* ต้องรองรับ Audit 100% + +- Running time: < 20ms ต่อคำสั่ง +- Hot reload ไม่ต้อง Compile ใหม่ทั้ง Backend +- DSL ต้อง Debug ได้ง่าย +- ต้อง Versioned +- ต้องรองรับ Audit 100% --- @@ -122,12 +122,8 @@ states: "transitions": { "SUBMIT": { "to": "IN_REVIEW", - "requirements": [ - { "role": "ENGINEER" } - ], - "events": [ - { "type": "notify", "target": "reviewer" } - ] + "requirements": [{ "role": "ENGINEER" }], + "events": [{ "type": "notify", "target": "reviewer" }] } } }, @@ -136,9 +132,7 @@ states: "APPROVE": { "to": "APPROVED" }, "REJECT": { "to": "DRAFT", - "events": [ - { "type": "notify", "target": "creator" } - ] + "events": [{ "type": "notify", "target": "creator" }] } } }, @@ -162,14 +156,14 @@ version = "version" ":" number ; states = "states:" state_list ; state_list = { state } ; -state = "- name:" identifier - [ "initial:" boolean ] +state = "- name:" identifier + [ "initial:" boolean ] [ "terminal:" boolean ] [ "on:" transition_list ] ; transition_list = { transition } ; -transition = action ":" +transition = action ":" indent "to:" identifier [ indent "require:" requirements ] [ indent "events:" event_list ] ; @@ -186,23 +180,23 @@ event = "- notify:" identifier ; #### 5.1 State Rules -* ต้องมีอย่างน้อย 1 state ที่ `initial: true` -* หาก `terminal: true` → ต้องไม่มี transition ต่อไป +- ต้องมีอย่างน้อย 1 state ที่ `initial: true` +- หาก `terminal: true` → ต้องไม่มี transition ต่อไป #### 5.2 Transition Rules ตรวจสอบว่า: -* `to` ชี้ไปยัง state ที่มีอยู่ -* `require.role` ต้องเป็น role ที่ระบบรู้จัก -* Action name ต้องเป็น **UPPER_CASE** +- `to` ชี้ไปยัง state ที่มีอยู่ +- `require.role` ต้องเป็น role ที่ระบบรู้จัก +- Action name ต้องเป็น **UPPER_CASE** #### 5.3 Version Safety -* ทุกชุด Workflow DSL ต้องขึ้นกับ version -* แก้ไขต้องสร้าง version ใหม่ -* ไม่ overwrite version เก่า -* “Document ที่กำลังอยู่ใน step เดิมยังต้องใช้กฎเดิมได้” +- ทุกชุด Workflow DSL ต้องขึ้นกับ version +- แก้ไขต้องสร้าง version ใหม่ +- ไม่ overwrite version เก่า +- “Document ที่กำลังอยู่ใน step เดิมยังต้องใช้กฎเดิมได้” --- @@ -240,14 +234,13 @@ interface WorkflowContext { ```ts class WorkflowEngine { - - load(dsl: string | object): CompiledWorkflow + load(dsl: string | object): CompiledWorkflow; - compile(dsl: string | object): CompiledWorkflow + compile(dsl: string | object): CompiledWorkflow; - evaluate(state: string, action: string, context: WorkflowContext): EvalResult + evaluate(state: string, action: string, context: WorkflowContext): EvalResult; - getAvailableActions(state: string, context: WorkflowContext): string[] + getAvailableActions(state: string, context: WorkflowContext): string[]; } ``` @@ -328,21 +321,21 @@ flowchart TD #### Unit Tests -* Parse DSL → JSON -* Invalid syntax → throw error -* Invalid transitions → throw error +- Parse DSL → JSON +- Invalid syntax → throw error +- Invalid transitions → throw error #### Integration Tests -* Evaluate() ผ่าน 20+ cases -* RFA ย้อนกลับ -* Approve chain -* Parallel review +- Evaluate() ผ่าน 20+ cases +- RFA ย้อนกลับ +- Approve chain +- Parallel review #### Load Tests -* 1,000 documents running workflow -* Evaluate < 20ms ต่อ action +- 1,000 documents running workflow +- Evaluate < 20ms ต่อ action --- @@ -350,9 +343,9 @@ flowchart TD #### Hot Reload Options -* DSL stored in DB -* Cache in Redis -* Touched timestamp triggers: +- DSL stored in DB +- Cache in Redis +- Touched timestamp triggers: ``` invalidate cache → recompile @@ -366,9 +359,9 @@ invalidate cache → recompile DSL Engine แยกเป็น: -* `workflow-engine-core` → Pure JS library -* `workflow-service` → NestJS module -* API public: +- `workflow-engine-core` → Pure JS library +- `workflow-service` → NestJS module +- API public: ``` POST /workflow/evaluate @@ -378,9 +371,9 @@ POST /workflow/compile ภายหลังสามารถนำไปวางบน: -* Kubernetes -* Worker Node -* API Gateway +- Kubernetes +- Worker Node +- API Gateway --- @@ -394,4 +387,3 @@ POST /workflow/compile ✔ Execution API สำหรับ Backend และ Frontend ✔ รองรับ Business Workflow ซับซ้อนทั้งหมด ✔ Ready สำหรับ Microservice model ในอนาคต - diff --git a/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_3.md b/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_3.md index c6d8fd4..4eb222d 100644 --- a/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_3.md +++ b/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_3.md @@ -5,13 +5,13 @@ **อ้างอิง:** Requirements v1.4.3 & FullStackJS Guidelines v1.4.3 **Classification:** Internal Technical Documentation ------ +--- ## 🎯 **ภาพรวมโครงการ** พัฒนา Backend สำหรับระบบบริหารจัดการเอกสารโครงการ (Document Management System) ที่มีความปลอดภัยสูง รองรับการทำงานพร้อมกัน (Concurrency) ได้อย่างถูกต้องแม่นยำ มีสถาปัตยกรรมที่ยืดหยุ่นต่อการขยายตัว และรองรับการจัดการเอกสารที่ซับซ้อน มีระบบ Workflow การอนุมัติ และการควบคุมสิทธิ์แบบ RBAC 4 ระดับ พร้อมมาตรการความปลอดภัยที่ทันสมัย ------ +--- ## 📐 **สถาปัตยกรรมระบบ** @@ -69,11 +69,11 @@ src/ └── database/ # Migrations & Seeds ``` ------ +--- ## 🗓️ **แผนการพัฒนาแบบ Phase-Based** -- *(Dependency Diagram ถูกละไว้เพื่อประหยัดพื้นที่ เนื่องจากมีการอ้างอิงจากแผนเดิม)* +- _(Dependency Diagram ถูกละไว้เพื่อประหยัดพื้นที่ เนื่องจากมีการอ้างอิงจากแผนเดิม)_ ## **Phase 0: Infrastructure & Configuration (สัปดาห์ที่ 1)** @@ -82,7 +82,6 @@ src/ ### **Phase 0: Tasks** - **[ ] T0.1 Secure Configuration Setup** - - [ ] ปรับปรุง `ConfigModule` ให้รองรับการอ่านค่าจาก Environment Variables - [ ] สร้าง Template `docker-compose.override.yml.example` สำหรับ Dev - [ ] Validate Config ด้วย Joi/Zod ตอน Start App (Throw error ถ้าขาด Secrets) @@ -91,7 +90,6 @@ src/ - [ ] **Dependencies:** None (Task เริ่มต้น) - **[ ] T0.2 Redis & Queue Infrastructure** - - [ ] Setup Redis Container - [ ] Setup BullMQ Module ใน NestJS สำหรับจัดการ Background Jobs - [ ] Setup Redis Client สำหรับ Distributed Lock (Redlock) @@ -100,7 +98,6 @@ src/ - [ ] **Dependencies:** T0.1 - **[ ] T0.3 Setup Database Connection** - - [ ] Import SQL Schema v1.4.2 เข้า MariaDB - [ ] Run Seed Data (organizations, users, roles, permissions) - [ ] Configure TypeORM ใน AppModule @@ -110,14 +107,13 @@ src/ - [ ] **Dependencies:** T0.1 - **[ ] T0.4 Setup Git Repository** - - [ ] สร้าง Repository ใน Gitea (git.np-dms.work) - [ ] Setup .gitignore, README.md, SECURITY.md - [ ] Commit Initial Project - [ ] **Deliverable:** Code อยู่ใน Version Control - [ ] **Dependencies:** T0.1, T0.2, T0.3 ------ +--- ## **Phase 1: Core Foundation & Security (สัปดาห์ที่ 2-3)** @@ -126,7 +122,6 @@ src/ ### **Phase 1: Tasks** - **[ ] T1.1 CommonModule - Base Infrastructure** - - [ ] สร้าง Base Entity (id, created_at, updated_at, deleted_at) - [ ] สร้าง Global Exception Filter (ไม่เปิดเผย sensitive information) - [ ] สร้าง Response Transform Interceptor @@ -141,7 +136,6 @@ src/ - [ ] **Dependencies:** T0.2, T0.3 - **[ ] T1.2 AuthModule - JWT Authentication** - - [ ] สร้าง Entity: User - [ ] สร้าง AuthService: - [ ] login(username, password) → JWT Token @@ -160,7 +154,6 @@ src/ - [ ] **Dependencies:** T1.1, T0.3 - **[ ] T1.3 UserModule - User Management** - - [ ] สร้าง Entities: User, Role, Permission, UserRole, UserAssignment, **UserPreference** - [ ] สร้าง UserService CRUD (พร้อม soft delete) - [ ] สร้าง RoleService CRUD @@ -181,7 +174,6 @@ src/ - [ ] **Dependencies:** T1.1, T1.2 - **[ ] T1.4 RBAC Guard - 4-Level Authorization** - - [ ] สร้าง @RequirePermission() Decorator - [ ] สร้าง RbacGuard ที่ตรวจสอบ 4 ระดับ: - [ ] Global Permissions @@ -195,7 +187,6 @@ src/ - [ ] **Dependencies:** T1.1, T1.3 - **[ ] T1.5 ProjectModule - Base Structures** - - [ ] สร้าง Entities: - [ ] Organization - [ ] Project @@ -211,7 +202,7 @@ src/ - [ ] **Deliverable:** จัดการโครงสร้างโปรเจกต์ได้ - [ ] **Dependencies:** T1.1, T1.2, T0.3 ------ +--- ## **Phase 2: High-Integrity Data & File Management (สัปดาห์ที่ 4)** @@ -220,7 +211,6 @@ src/ ### **Phase 2: Tasks** - **[ ] T2.1 Virtual Columns for JSON** - - [ ] ออกแบบ Migration Script สำหรับตารางที่มี JSON Details - [ ] เพิ่ม **Generated Columns (Virtual)** สำหรับฟิลด์ที่ใช้ Search บ่อยๆ (เช่น `project_id`, `type`) พร้อม Index - [ ] **Security:** Implement admin-only access สำหรับ master data @@ -228,7 +218,6 @@ src/ - [ ] **Dependencies:** T0.3, T1.1, T1.5 - **[ ] T2.2 FileStorageService - Two-Phase Storage** - - [ ] สร้าง Attachment Entity - [ ] สร้าง FileStorageService: - [ ] **Phase 1 (Upload):** API รับไฟล์ → Scan Virus → Save ลง `temp/` → Return `temp_id` @@ -246,18 +235,12 @@ src/ - [ ] **Dependencies:** T1.1, T1.4 - **[ ] T2.3 DocumentNumberingModule - Double-Lock Mechanism** - - [ ] สร้าง Entities: - [ ] DocumentNumberFormat - [ ] DocumentNumberCounter - [ ] สร้าง DocumentNumberingService: - [ ] generateNextNumber(projectId, orgId, typeId, year) → string - - [ ] ใช้ **Double-Lock Mechanism**: - 1. Acquire **Redis Lock** (Key: `doc_num:{project}:{type}`) - 2. Read DB & Calculate Next Number - 3. Update DB with **Optimistic Lock** Check (ใช้ `@VersionColumn()`) - 4. Release Redis Lock - 5. Retry on Failure ด้วย exponential backoff + - [ ] ใช้ **Double-Lock Mechanism**: 1. Acquire **Redis Lock** (Key: `doc_num:{project}:{type}`) 2. Read DB & Calculate Next Number 3. Update DB with **Optimistic Lock** Check (ใช้ `@VersionColumn()`) 4. Release Redis Lock 5. Retry on Failure ด้วย exponential backoff - [ ] Fallback mechanism เมื่อการขอเลขล้มเหลว - [ ] Format ตาม Template: {ORG_CODE}-{TYPE_CODE}-{YEAR_SHORT}-{SEQ:4} - **ไม่มี Controller** (Internal Service เท่านั้น) @@ -266,7 +249,6 @@ src/ - [ ] **Dependencies:** T1.1, T0.3 - **[ ] T2.4 SecurityModule - Enhanced Security** - - [ ] สร้าง Input Validation Service: - [ ] XSS Prevention - [ ] SQL Injection Prevention @@ -280,14 +262,13 @@ src/ - [ ] **Dependencies:** T1.1 - **[ ] T2.5 JSON Details & Schema Management** - - [ ] T2.5.1 JsonSchemaModule - Schema Management: สร้าง Service สำหรับ Validate, get, register JSON schemas - [ ] T2.5.2 DetailsService - Data Processing: สร้าง Service สำหรับ sanitize, transform, compress/decompress JSON - [ ] T2.5.3 JSON Security & Validation: Implement security checks และ validation rules - [ ] **Deliverable:** JSON schema system ทำงานได้ - [ ] **Dependencies:** T1.1 ------ +--- ## **Phase 3: Unified Workflow Engine (สัปดาห์ที่ 5-6)** @@ -296,7 +277,6 @@ src/ ### **Phase 3: Tasks** - **[ ] T3.1 WorkflowEngineModule (New)** - - [ ] ออกแบบ Generic Schema สำหรับ Workflow State Machine - [ ] Implement Service: `initializeWorkflow()`, `processAction()`, `getNextStep()` - [ ] รองรับ Logic การ "ข้ามขั้นตอน" และ "ส่งกลับ" ภายใน Engine เดียว @@ -305,7 +285,6 @@ src/ - [ ] **Dependencies:** T1.1 - **[ ] T3.2 CorrespondenceModule - Basic CRUD** - - [ ] สร้าง Entities (Correspondence, Revision, Recipient, Tag, Reference, Attachment) - [ ] สร้าง CorrespondenceService (Create with Document Numbering, Update with new Revision, Soft Delete) - [ ] สร้าง Controllers (POST/GET/PUT/DELETE /correspondences) @@ -315,7 +294,6 @@ src/ - [ ] **Dependencies:** T1.1, T1.2, T1.3, T1.4, T1.5, T2.3, T2.2, T2.5 - **[ ] T3.3 CorrespondenceModule - Advanced Features** - - [ ] Implement Status Transitions (DRAFT → SUBMITTED) - [ ] Implement References (Link Documents) - [ ] Implement Search (Basic) @@ -324,7 +302,6 @@ src/ - [ ] **Dependencies:** T3.2 - **[ ] T3.4 Correspondence Integration with Workflow** - - [ ] เชื่อมต่อ `CorrespondenceService` เข้ากับ `WorkflowEngineModule` - [ ] ย้าย Logic การ Routing เดิมมาใช้ Engine ใหม่ - [ ] สร้าง API endpoints สำหรับ Frontend (Templates, Pending Tasks, Bulk Action) @@ -332,7 +309,7 @@ src/ - [ ] **Deliverable:** ระบบส่งต่อเอกสารทำงานได้สมบูรณ์ด้วย Unified Engine - [ ] **Dependencies:** T3.1, T3.2 ------ +--- ## **Phase 4: Drawing & Advanced Workflows (สัปดาห์ที่ 7-8)** @@ -341,7 +318,6 @@ src/ ### **Phase 4: Tasks** - **[ ] T4.1 DrawingModule - Contract Drawings** - - [ ] สร้าง Entities (ContractDrawing, Volume, Category, SubCategory, Attachment) - [ ] สร้าง ContractDrawingService CRUD - [ ] สร้าง Controllers (GET/POST /drawings/contract) @@ -350,7 +326,6 @@ src/ - [ ] **Dependencies:** T1.1, T1.2, T1.4, T1.5, T2.2 - **[ ] T4.2 DrawingModule - Shop Drawings** - - [ ] สร้าง Entities (ShopDrawing, Revision, Main/SubCategory, ContractRef, RevisionAttachment) - [ ] สร้าง ShopDrawingService CRUD (รวมการสร้าง Revision) - [ ] สร้าง Controllers (GET/POST /drawings/shop, /drawings/shop/:id/revisions) @@ -360,7 +335,6 @@ src/ - [ ] **Dependencies:** T4.1 - **[ ] T5.1 RfaModule with Unified Workflow** - - [ ] สร้าง Entities (Rfa, RfaRevision, RfaItem, RfaWorkflowTemplate/Step) - [ ] สร้าง RfaService (Create RFA, Link Shop Drawings) - [ ] Implement RFA Workflow โดยใช้ Configuration ของ `WorkflowEngineModule` @@ -369,7 +343,7 @@ src/ - [ ] **Deliverable:** RFA Workflow ทำงานได้ด้วย Unified Engine - [ ] **Dependencies:** T3.2, T4.2, T2.5, T6.2 ------ +--- ## **Phase 5: Workflow Systems & Resilience (สัปดาห์ที่ 8-9)** @@ -378,7 +352,6 @@ src/ ### **Phase 5: Tasks** - **[ ] T5.2 CirculationModule - Internal Routing** - - [ ] สร้าง Entities (Circulation, Template, Routing, Attachment) - [ ] สร้าง CirculationService (Create 1:1 with Correspondence, Assign User, Complete/Close Step) - [ ] สร้าง Controllers (POST/GET /circulations, POST /circulations/:id/steps/...) @@ -387,7 +360,6 @@ src/ - [ ] **Dependencies:** T3.2, T2.5, T6.2 - **[ ] T5.3 TransmittalModule - Document Forwarding** - - [ ] สร้าง Entities (Transmittal, TransmittalItem) - [ ] สร้าง TransmittalService (Create Correspondence + Transmittal, Link Multiple Correspondences) - [ ] สร้าง Controllers (POST/GET /transmittals) @@ -395,7 +367,7 @@ src/ - [ ] **Deliverable:** สร้าง Transmittal ได้ - [ ] **Dependencies:** T3.2 ------ +--- ## **Phase 6: Notification & Resilience (สัปดาห์ที่ 9)** @@ -404,7 +376,6 @@ src/ ### **Phase 6: Tasks** - **[ ] T6.1 SearchModule - Elasticsearch Integration** - - [ ] Setup Elasticsearch Container - [ ] สร้าง SearchService (index/update/delete documents, search) - [ ] Index ทุก Document Type @@ -414,7 +385,6 @@ src/ - [ ] **Dependencies:** T3.2, T5.1, T4.2, T5.2, T5.3 - **[ ] T6.2 Notification Queue & Digest** - - [ ] สร้าง NotificationService (sendEmail/Line/System) - [ ] **Producer:** Push Event ลง BullMQ Queue - [ ] **Consumer:** จัดกลุ่ม Notification (Digest Message) และส่งผ่าน Email/Line @@ -425,7 +395,6 @@ src/ - [ ] **Dependencies:** T1.1, T6.4 - **[ ] T6.3 MonitoringModule - Observability** - - [ ] สร้าง Health Check Controller (GET /health) - [ ] สร้าง Metrics Service (API response times, Error rates) - [ ] สร้าง Performance Interceptor (Track request duration) @@ -434,7 +403,6 @@ src/ - [ ] **Dependencies:** T1.1 - **[ ] T6.4 ResilienceModule - Circuit Breaker & Retry** - - [ ] สร้าง Circuit Breaker Service (@CircuitBreaker() decorator) - [ ] สร้าง Retry Service (@Retry() decorator) - [ ] สร้าง Fallback Strategies @@ -443,13 +411,12 @@ src/ - [ ] **Dependencies:** T1.1 - **[ ] T6.5 Data Partitioning Strategy** - - [ ] ออกแบบ Table Partitioning สำหรับ `audit_logs` และ `notifications` (แบ่งตาม Range: Year) - [ ] เขียน Raw SQL Migration สำหรับสร้าง Partition Table - [ ] **Deliverable:** Database Performance และ Scalability ดีขึ้น - [ ] **Dependencies:** T0.3 ------ +--- ## **Phase 7: Testing & Hardening (สัปดาห์ที่ 10-12)** @@ -458,21 +425,18 @@ src/ ### **Phase 7: Tasks** - **[ ] T7.1 Concurrency Testing** - - [ ] เขียน Test Scenarios ยิง Request ขอเลขที่เอกสารพร้อมกัน 100 Request (ต้องไม่ซ้ำและไม่ข้าม) - [ ] ทดสอบ Optimistic Lock ทำงานถูกต้องเมื่อ Redis ถูกปิด - [ ] ทดสอบ File Upload พร้อมกันหลายไฟล์ - [ ] **Deliverable:** ระบบทนทานต่อ Concurrency Issues - **[ ] T7.2 Transaction Integrity Testing** - - [ ] ทดสอบ Upload ไฟล์แล้ว Kill Process ก่อน Commit - [ ] ทดสอบ Two-Phase File Storage ทำงานถูกต้อง - [ ] ทดสอบ Database Transaction Rollback Scenarios - [ ] **Deliverable:** Data Integrity รับประกันได้ - **[ ] T7.3 Security & Idempotency Test** - - [ ] ทดสอบ Replay Attack โดยใช้ `Idempotency-Key` ซ้ำ - [ ] ทดสอบ Maintenance Mode Block API ได้จริง - [ ] ทดสอบ RBAC 4-Level ทำงานถูกต้อง 100% @@ -485,7 +449,6 @@ src/ - **[ ] T7.6 E2E Testing** - **[ ] T7.7 Performance Testing** - - [ ] Load Testing: 100 concurrent users - [ ] **(สำคัญ)** การจูนและทดสอบ Load Test จะต้องทำในสภาพแวดล้อมที่จำลอง Spec ของ QNAP Server (TS-473A, AMD Ryzen V1500B) เพื่อให้ได้ค่า Response Time และ Connection Pool ที่เที่ยงตรง - [ ] Stress Testing @@ -493,19 +456,17 @@ src/ - [ ] **Deliverable:** Performance targets บรรลุ - **[ ] T7.8 Security Testing** - - [ ] Penetration Testing (OWASP Top 10) - [ ] Security Audit (Code review, Dependency scanning) - [ ] File Upload Security Testing - [ ] **Deliverable:** Security tests ผ่าน - **[ ] T7.9 Performance Optimization** - - [ ] Implement Caching (Master Data, User Permissions, Search Results) - [ ] Database Optimization (Review Indexes, Query Optimization, Pagination) - [ ] **Deliverable:** Response Time < 200ms (90th percentile) ------ +--- ## **Phase 8: Documentation & Deployment (สัปดาห์ที่ 14)** @@ -520,12 +481,12 @@ src/ - **[ ] T8.5 Production Deployment** - **[ ] T8.6 Handover to Frontend Team** ------ +--- ## 📊 **สรุป Timeline** -| Phase | ระยะเวลา | จำนวนงาน | Output หลัก | -| :------ | :----------- | :----------- | :--------------------------------------------- | +| Phase | ระยะเวลา | จำนวนงาน | Output หลัก | +| :------ | :------------- | :----------- | :--------------------------------------------- | | Phase 0 | 1 สัปดาห์ | 4 | Infrastructure Ready + Security Base | | Phase 1 | 2 สัปดาห์ | 5 | Auth & User Management + RBAC + Idempotency | | Phase 2 | 1 สัปดาห์ | 5 | High-Integrity Data & File Management | @@ -547,6 +508,6 @@ src/ - **Classification:** Internal Technical Documentation - **Approved By:** Nattanin ------ +--- `End of Backend Development Plan v1.4.3` diff --git a/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_4.Phase6A.md b/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_4.Phase6A.md index 6e75a61..3be78b0 100644 --- a/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_4.Phase6A.md +++ b/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_4.Phase6A.md @@ -1,4 +1,5 @@ -# “Phase 6A + Technical Design Document : Workflow DSL (Mini-Language)”** +# “Phase 6A + Technical Design Document : Workflow DSL (Mini-Language)”\*\* + ออกแบบสำหรับระบบ Workflow Engine กลางของโครงการ **ไม่มีโค้ดผูกกับ Framework** เพื่อให้สามารถนำไป Implement ใน NestJS หรือ Microservice ใด ๆ ได้ @@ -10,20 +11,19 @@ ใน Phase นี้ จะเริ่มสร้าง “Workflow DSL (Domain-Specific Language)” สำหรับนิยามกฎการเดินงาน (Workflow Transition Rules) ให้สามารถ: -* แยก **Business Workflow Logic** ออกจาก Source Code -* แก้ไขกฎ Workflow ได้โดย **ไม่ต้องแก้โค้ดและไม่ต้อง Deploy ใหม่** -* รองรับ Document หลายประเภท เช่น +- แยก **Business Workflow Logic** ออกจาก Source Code +- แก้ไขกฎ Workflow ได้โดย **ไม่ต้องแก้โค้ดและไม่ต้อง Deploy ใหม่** +- รองรับ Document หลายประเภท เช่น + - Correspondence + - RFA + - Internal Circulation + - Document Transmittal - * Correspondence - * RFA - * Internal Circulation - * Document Transmittal -* รองรับ Multi-step routing, skip, reject, rollback, parallel assignments -* สามารถนำไปใช้งานทั้งใน - - * Backend (NestJS) - * Frontend (UI Driven) - * External Microservices +- รองรับ Multi-step routing, skip, reject, rollback, parallel assignments +- สามารถนำไปใช้งานทั้งใน + - Backend (NestJS) + - Frontend (UI Driven) + - External Microservices --- @@ -35,12 +35,12 @@ ### 🧩 Output ของ Phase 6A -* DSL Specification (Grammar) -* JSON Schema for Workflow Definition -* Workflow Rule Interpreter (Parser + Executor) -* Validation Engine (Compile-time and Runtime) -* Storage (DB Table / Registry) -* Execution API: +- DSL Specification (Grammar) +- JSON Schema for Workflow Definition +- Workflow Rule Interpreter (Parser + Executor) +- Validation Engine (Compile-time and Runtime) +- Storage (DB Table / Registry) +- Execution API: | Action | Description | | -------------------------------- | ------------------------------- | @@ -59,22 +59,22 @@ #### Functional Requirements -* นิยาม Workflow เป็นภาษาคล้าย State Machine -* แต่ละเอกสารมี **State, Actions, Entry/Exit Events** -* สามารถมี: - - * Required approvals - * Conditional transition - * Auto-transition - * Parallel approval - * Return/rollback +- นิยาม Workflow เป็นภาษาคล้าย State Machine +- แต่ละเอกสารมี **State, Actions, Entry/Exit Events** +- สามารถมี: + - Required approvals + - Conditional transition + - Auto-transition + - Parallel approval + - Return/rollback #### -* Running time: < 20ms ต่อคำสั่ง -* Hot reload ไม่ต้อง Compile ใหม่ทั้ง Backend -* DSL ต้อง Debug ได้ง่าย -* ต้อง Versioned -* ต้องรองรับ Audit 100% + +- Running time: < 20ms ต่อคำสั่ง +- Hot reload ไม่ต้อง Compile ใหม่ทั้ง Backend +- DSL ต้อง Debug ได้ง่าย +- ต้อง Versioned +- ต้องรองรับ Audit 100% --- @@ -122,12 +122,8 @@ states: "transitions": { "SUBMIT": { "to": "IN_REVIEW", - "requirements": [ - { "role": "ENGINEER" } - ], - "events": [ - { "type": "notify", "target": "reviewer" } - ] + "requirements": [{ "role": "ENGINEER" }], + "events": [{ "type": "notify", "target": "reviewer" }] } } }, @@ -136,9 +132,7 @@ states: "APPROVE": { "to": "APPROVED" }, "REJECT": { "to": "DRAFT", - "events": [ - { "type": "notify", "target": "creator" } - ] + "events": [{ "type": "notify", "target": "creator" }] } } }, @@ -162,14 +156,14 @@ version = "version" ":" number ; states = "states:" state_list ; state_list = { state } ; -state = "- name:" identifier - [ "initial:" boolean ] +state = "- name:" identifier + [ "initial:" boolean ] [ "terminal:" boolean ] [ "on:" transition_list ] ; transition_list = { transition } ; -transition = action ":" +transition = action ":" indent "to:" identifier [ indent "require:" requirements ] [ indent "events:" event_list ] ; @@ -186,23 +180,23 @@ event = "- notify:" identifier ; #### 5.1 State Rules -* ต้องมีอย่างน้อย 1 state ที่ `initial: true` -* หาก `terminal: true` → ต้องไม่มี transition ต่อไป +- ต้องมีอย่างน้อย 1 state ที่ `initial: true` +- หาก `terminal: true` → ต้องไม่มี transition ต่อไป #### 5.2 Transition Rules ตรวจสอบว่า: -* `to` ชี้ไปยัง state ที่มีอยู่ -* `require.role` ต้องเป็น role ที่ระบบรู้จัก -* Action name ต้องเป็น **UPPER_CASE** +- `to` ชี้ไปยัง state ที่มีอยู่ +- `require.role` ต้องเป็น role ที่ระบบรู้จัก +- Action name ต้องเป็น **UPPER_CASE** #### 5.3 Version Safety -* ทุกชุด Workflow DSL ต้องขึ้นกับ version -* แก้ไขต้องสร้าง version ใหม่ -* ไม่ overwrite version เก่า -* “Document ที่กำลังอยู่ใน step เดิมยังต้องใช้กฎเดิมได้” +- ทุกชุด Workflow DSL ต้องขึ้นกับ version +- แก้ไขต้องสร้าง version ใหม่ +- ไม่ overwrite version เก่า +- “Document ที่กำลังอยู่ใน step เดิมยังต้องใช้กฎเดิมได้” --- @@ -240,14 +234,13 @@ interface WorkflowContext { ```ts class WorkflowEngine { - - load(dsl: string | object): CompiledWorkflow + load(dsl: string | object): CompiledWorkflow; - compile(dsl: string | object): CompiledWorkflow + compile(dsl: string | object): CompiledWorkflow; - evaluate(state: string, action: string, context: WorkflowContext): EvalResult + evaluate(state: string, action: string, context: WorkflowContext): EvalResult; - getAvailableActions(state: string, context: WorkflowContext): string[] + getAvailableActions(state: string, context: WorkflowContext): string[]; } ``` @@ -328,21 +321,21 @@ flowchart TD #### Unit Tests -* Parse DSL → JSON -* Invalid syntax → throw error -* Invalid transitions → throw error +- Parse DSL → JSON +- Invalid syntax → throw error +- Invalid transitions → throw error #### Integration Tests -* Evaluate() ผ่าน 20+ cases -* RFA ย้อนกลับ -* Approve chain -* Parallel review +- Evaluate() ผ่าน 20+ cases +- RFA ย้อนกลับ +- Approve chain +- Parallel review #### Load Tests -* 1,000 documents running workflow -* Evaluate < 20ms ต่อ action +- 1,000 documents running workflow +- Evaluate < 20ms ต่อ action --- @@ -350,9 +343,9 @@ flowchart TD #### Hot Reload Options -* DSL stored in DB -* Cache in Redis -* Touched timestamp triggers: +- DSL stored in DB +- Cache in Redis +- Touched timestamp triggers: ``` invalidate cache → recompile @@ -366,9 +359,9 @@ invalidate cache → recompile DSL Engine แยกเป็น: -* `workflow-engine-core` → Pure JS library -* `workflow-service` → NestJS module -* API public: +- `workflow-engine-core` → Pure JS library +- `workflow-service` → NestJS module +- API public: ``` POST /workflow/evaluate @@ -378,9 +371,9 @@ POST /workflow/compile ภายหลังสามารถนำไปวางบน: -* Kubernetes -* Worker Node -* API Gateway +- Kubernetes +- Worker Node +- API Gateway --- @@ -394,4 +387,3 @@ POST /workflow/compile ✔ Execution API สำหรับ Backend และ Frontend ✔ รองรับ Business Workflow ซับซ้อนทั้งหมด ✔ Ready สำหรับ Microservice model ในอนาคต - diff --git a/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_4.md b/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_4.md index f9cb330..606f7d2 100644 --- a/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_4.md +++ b/specs/99-archives/docs/Markdown/2_Backend_Plan_V1_4_4.md @@ -353,7 +353,6 @@ ### **Phase 0: Tasks** - **[ ] T0.1 Secure Configuration Setup** - - [ ] ปรับปรุง `ConfigModule` ให้รองรับการอ่านค่าจาก Environment Variables - [ ] สร้าง Template `docker-compose.override.yml.example` สำหรับ Dev - [ ] Validate Config ด้วย Joi/Zod ตอน Start App (Throw error ถ้าขาด Secrets) @@ -362,7 +361,6 @@ - [ ] **Dependencies:** None (Task เริ่มต้น) - **[ ] T0.2 Redis & Queue Infrastructure** - - [ ] Setup Redis Container - [ ] Setup BullMQ Module ใน NestJS สำหรับจัดการ Background Jobs - [ ] Setup Redis Client สำหรับ Distributed Lock (Redlock) @@ -371,7 +369,6 @@ - [ ] **Dependencies:** T0.1 - **[ ] T0.3 Setup Database Connection** - - [ ] Import SQL Schema v1.4.2 เข้า MariaDB - [ ] Run Seed Data (organizations, users, roles, permissions) - [ ] Configure TypeORM ใน AppModule @@ -381,7 +378,6 @@ - [ ] **Dependencies:** T0.1 - **[ ] T0.4 Setup Git Repository** - - [ ] สร้าง Repository ใน Gitea (git.np-dms.work) - [ ] Setup .gitignore, README.md, SECURITY.md - [ ] Commit Initial Project @@ -397,7 +393,6 @@ ### **Phase 1: Tasks** - **[ ] T1.1 CommonModule - Base Infrastructure** - - [ ] สร้าง Base Entity (id, created_at, updated_at, deleted_at) - [ ] สร้าง Global Exception Filter (ไม่เปิดเผย sensitive information) - [ ] สร้าง Response Transform Interceptor @@ -412,7 +407,6 @@ - [ ] **Dependencies:** T0.2, T0.3 - **[ ] T1.2 AuthModule - JWT Authentication** - - [ ] สร้าง Entity: User - [ ] สร้าง AuthService: - [ ] login(username, password) → JWT Token @@ -431,7 +425,6 @@ - [ ] **Dependencies:** T1.1, T0.3 - **[ ] T1.3 UserModule - User Management** - - [ ] สร้าง Entities: User, Role, Permission, UserRole, UserAssignment, **UserPreference** - [ ] สร้าง UserService CRUD (พร้อม soft delete) - [ ] สร้าง RoleService CRUD @@ -452,7 +445,6 @@ - [ ] **Dependencies:** T1.1, T1.2 - **[ ] T1.4 RBAC Guard - 4-Level Authorization** - - [ ] สร้าง @RequirePermission() Decorator - [ ] สร้าง RbacGuard ที่ตรวจสอบ 4 ระดับ: - [ ] Global Permissions @@ -466,7 +458,6 @@ - [ ] **Dependencies:** T1.1, T1.3 - **[ ] T1.5 ProjectModule - Base Structures** - - [ ] สร้าง Entities: - [ ] Organization - [ ] Project @@ -491,7 +482,6 @@ ### **Phase 2: Tasks** - **[ ] T2.1 Virtual Columns for JSON** - - [ ] ออกแบบ Migration Script สำหรับตารางที่มี JSON Details - [ ] เพิ่ม **Generated Columns (Virtual)** สำหรับฟิลด์ที่ใช้ Search บ่อยๆ (เช่น `project_id`, `type`) พร้อม Index - [ ] **Security:** Implement admin-only access สำหรับ master data @@ -499,7 +489,6 @@ - [ ] **Dependencies:** T0.3, T1.1, T1.5 - **[ ] T2.2 FileStorageService - Two-Phase Storage** - - [ ] สร้าง Attachment Entity - [ ] สร้าง FileStorageService: - [ ] **Phase 1 (Upload):** API รับไฟล์ → Scan Virus → Save ลง `temp/` → Return `temp_id` @@ -517,7 +506,6 @@ - [ ] **Dependencies:** T1.1, T1.4 - **[ ] T2.3 DocumentNumberingModule - Double-Lock Mechanism** - - [ ] สร้าง Entities: - [ ] DocumentNumberFormat - [ ] DocumentNumberCounter @@ -538,7 +526,6 @@ - [ ] **Deliverable:** Flexible Numbering System - **[ ] T2.4 SecurityModule - Enhanced Security** - - [ ] สร้าง Input Validation Service: - [ ] XSS Prevention - [ ] SQL Injection Prevention @@ -552,7 +539,6 @@ - [ ] **Dependencies:** T1.1 - **[ ] T2.5 JSON Details & Schema Management** - - [ ] T2.5.1 JsonSchemaModule - Schema Management: สร้าง Service สำหรับ Validate, get, register JSON schemas - [ ] T2.5.2 DetailsService - Data Processing: สร้าง Service สำหรับ sanitize, transform, compress/decompress JSON - [ ] T2.5.3 JSON Security & Validation: Implement security checks และ validation rules @@ -654,11 +640,7 @@ export class JsonSchemaService { this.registerCustomValidators(); } - async validateData( - schemaName: string, - data: any, - options: ValidationOptions = {} - ): Promise { + async validateData(schemaName: string, data: any, options: ValidationOptions = {}): Promise { const schema = await this.getSchema(schemaName); const validate = this.ajv.compile(schema); @@ -682,11 +664,7 @@ export class JsonSchemaService { }; } - private async sanitizeData( - data: any, - schema: any, - options: ValidationOptions - ): Promise { + private async sanitizeData(data: any, schema: any, options: ValidationOptions): Promise { const sanitized = { ...data }; // Remove unknown properties if not allowed @@ -745,10 +723,7 @@ export class VirtualColumnService { private configService: ConfigService ) {} - async setupVirtualColumns( - tableName: string, - schemaConfig: VirtualColumnConfig[] - ): Promise { + async setupVirtualColumns(tableName: string, schemaConfig: VirtualColumnConfig[]): Promise { const connection = this.dataSource.manager.connection; for (const config of schemaConfig) { @@ -756,10 +731,7 @@ export class VirtualColumnService { } } - private async createVirtualColumn( - tableName: string, - config: VirtualColumnConfig - ): Promise { + private async createVirtualColumn(tableName: string, config: VirtualColumnConfig): Promise { const columnDefinition = this.generateColumnDefinition(config); const sql = ` @@ -782,10 +754,7 @@ export class VirtualColumnService { return `${dataType} GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(details, '${jsonPath}'))) VIRTUAL`; } - private async createIndex( - tableName: string, - config: VirtualColumnConfig - ): Promise { + private async createIndex(tableName: string, config: VirtualColumnConfig): Promise { const indexName = `idx_${tableName}_${config.column_name}`; const sql = ` CREATE ${config.index_type} INDEX ${indexName} @@ -902,13 +871,7 @@ const rfaDwgSchema: UiSchema = { widget: 'select', title: 'Discipline', enum: ['CIVIL', 'STRUCTURAL', 'MECHANICAL', 'ELECTRICAL', 'PLUMBING'], - enumNames: [ - 'Civil', - 'Structural', - 'Mechanical', - 'Electrical', - 'Plumbing', - ], + enumNames: ['Civil', 'Structural', 'Mechanical', 'Electrical', 'Plumbing'], }, drawingReferences: { type: 'array', @@ -941,21 +904,11 @@ const rfaDwgSchema: UiSchema = { ```typescript @Injectable() export class SchemaMigrationService { - async migrateData( - entityType: string, - entityId: string, - targetVersion: number - ): Promise { + async migrateData(entityType: string, entityId: string, targetVersion: number): Promise { const currentData = await this.getCurrentData(entityType, entityId); - const currentVersion = await this.getCurrentSchemaVersion( - entityType, - entityId - ); + const currentVersion = await this.getCurrentSchemaVersion(entityType, entityId); - const migrationPath = await this.findMigrationPath( - currentVersion, - targetVersion - ); + const migrationPath = await this.findMigrationPath(currentVersion, targetVersion); let migratedData = currentData; @@ -964,24 +917,13 @@ export class SchemaMigrationService { } // Validate migrated data against target schema - const validationResult = await this.validateAgainstSchema( - migratedData, - targetVersion - ); + const validationResult = await this.validateAgainstSchema(migratedData, targetVersion); if (!validationResult.isValid) { - throw new MigrationError( - 'MIGRATION_VALIDATION_FAILED', - validationResult.errors - ); + throw new MigrationError('MIGRATION_VALIDATION_FAILED', validationResult.errors); } - await this.saveMigratedData( - entityType, - entityId, - migratedData, - targetVersion - ); + await this.saveMigratedData(entityType, entityId, migratedData, targetVersion); return { success: true, @@ -991,10 +933,7 @@ export class SchemaMigrationService { }; } - private async applyMigrationStep( - step: MigrationStep, - data: any - ): Promise { + private async applyMigrationStep(step: MigrationStep, data: any): Promise { switch (step.type) { case 'FIELD_RENAME': return this.renameField(data, step.config); @@ -1050,11 +989,7 @@ const migrationSteps = [ ```typescript @Injectable() export class JsonSecurityService { - async applyFieldLevelSecurity( - data: any, - schema: any, - userContext: UserContext - ): Promise { + async applyFieldLevelSecurity(data: any, schema: any, userContext: UserContext): Promise { const securedData = { ...data }; const securityRules = await this.getSecurityRules(schema.name); @@ -1081,10 +1016,7 @@ export class JsonSecurityService { for (const fieldPath of sensitiveFields) { const fieldValue = this.getFieldValue(data, fieldPath); if (fieldValue) { - const encrypted = await this.cryptoService.encrypt( - fieldValue, - 'field-level' - ); + const encrypted = await this.cryptoService.encrypt(fieldValue, 'field-level'); this.setFieldValue(encryptedData, fieldPath, encrypted); } } @@ -1129,15 +1061,8 @@ export class JsonSecurityService { export class JsonSchemaController { @Post('validate/:schemaName') @RequirePermission('schema.validate') - async validateData( - @Param('schemaName') schemaName: string, - @Body() dto: ValidateDataDto - ): Promise { - return this.jsonSchemaService.validateData( - schemaName, - dto.data, - dto.options - ); + async validateData(@Param('schemaName') schemaName: string, @Body() dto: ValidateDataDto): Promise { + return this.jsonSchemaService.validateData(schemaName, dto.data, dto.options); } @Post('schemas') @@ -1153,18 +1078,12 @@ export class JsonSchemaController { @Param('entityId') entityId: string, @Body() dto: MigrateDataDto ): Promise { - return this.migrationService.migrateData( - entityType, - entityId, - dto.targetVersion - ); + return this.migrationService.migrateData(entityType, entityId, dto.targetVersion); } @Get('ui-schema/:schemaName') @RequirePermission('schema.view') - async getUiSchema( - @Param('schemaName') schemaName: string - ): Promise { + async getUiSchema(@Param('schemaName') schemaName: string): Promise { return this.schemaService.getUiSchema(schemaName); } } @@ -1186,32 +1105,22 @@ export class CorrespondenceService { private detailsService: DetailsService ) {} - async createCorrespondence( - dto: CreateCorrespondenceDto - ): Promise { + async createCorrespondence(dto: CreateCorrespondenceDto): Promise { // 1. Validate details against schema - const validationResult = await this.jsonSchemaService.validateData( - `CORRESPONDENCE_${dto.type}`, - dto.details - ); + const validationResult = await this.jsonSchemaService.validateData(`CORRESPONDENCE_${dto.type}`, dto.details); if (!validationResult.isValid) { throw new ValidationError('INVALID_DETAILS', validationResult.errors); } // 2. Apply security and sanitization - const secureDetails = await this.detailsService.sanitizeDetails( - validationResult.sanitizedData, - dto.type - ); + const secureDetails = await this.detailsService.sanitizeDetails(validationResult.sanitizedData, dto.type); // 3. Create correspondence entity const correspondence = this.correspondenceRepository.create({ ...dto, details: secureDetails, - schema_version: await this.getCurrentSchemaVersion( - `CORRESPONDENCE_${dto.type}` - ), + schema_version: await this.getCurrentSchemaVersion(`CORRESPONDENCE_${dto.type}`), }); // 4. Setup virtual columns for performance @@ -1220,9 +1129,7 @@ export class CorrespondenceService { return this.correspondenceRepository.save(correspondence); } - async searchCorrespondences( - filters: SearchFilters - ): Promise { + async searchCorrespondences(filters: SearchFilters): Promise { // Use virtual columns for efficient filtering const query = this.correspondenceRepository.createQueryBuilder('c'); @@ -1343,7 +1250,6 @@ describe('VirtualColumnService', () => { ### **Phase 3: Tasks** - **[ ] T3.1 WorkflowEngineModule (New)** - - [ ] ออกแบบ Generic Schema สำหรับ Workflow State Machine - [ ] Implement Service: `initializeWorkflow()`, `processAction()`, `getNextStep()` - [ ] รองรับ Logic การ "ข้ามขั้นตอน" และ "ส่งกลับ" ภายใน Engine เดียว @@ -1418,7 +1324,6 @@ states: ``` - **[ ] T3.1.2 Workflow Core Entities & Database Schema** - - [ ] WorkflowDefinition Entity - [ ] WorkflowInstance Entity - [ ] WorkflowHistory Entity @@ -1485,7 +1390,6 @@ export class WorkflowInstance { ``` - **[ ] T3.1.3 DSL Parser & Compiler Service** - - [ ] YAML Parser สำหรับอ่าน DSL definitions - [ ] Syntax Validator สำหรับ compile-time validation - [ ] Schema Compiler สำหรับแปลง DSL → Normalized JSON @@ -1517,9 +1421,7 @@ export class WorkflowDslService { // Terminal states ต้องไม่มี transitions () => !definition.states.filter((s) => s.terminal).some((s) => s.on), // State names must be unique - () => - new Set(definition.states.map((s) => s.name)).size === - definition.states.length, + () => new Set(definition.states.map((s) => s.name)).size === definition.states.length, // Transition targets must exist () => this.validateTransitionTargets(definition), ]; @@ -1663,11 +1565,7 @@ interface StateWithTimeout { ```typescript @Injectable() export class WorkflowEventService { - async executeEvents( - events: WorkflowEvent[], - instance: WorkflowInstance, - context: WorkflowContext - ): Promise { + async executeEvents(events: WorkflowEvent[], instance: WorkflowInstance, context: WorkflowContext): Promise { for (const event of events) { switch (event.type) { case 'notify': @@ -1691,16 +1589,8 @@ export class WorkflowEventService { instance: WorkflowInstance, context: WorkflowContext ): Promise { - const recipients = await this.resolveRecipients( - event.target, - instance, - context - ); - const message = await this.renderTemplate( - event.template, - instance, - context - ); + const recipients = await this.resolveRecipients(event.target, instance, context); + const message = await this.renderTemplate(event.template, instance, context); await this.notificationService.send({ type: 'workflow', @@ -1732,35 +1622,24 @@ export class WorkflowEngineController { @Param('id') instanceId: string, @Body() dto: WorkflowTransitionDto ): Promise { - return this.workflowEngine.processTransition( - instanceId, - dto.action, - dto.context - ); + return this.workflowEngine.processTransition(instanceId, dto.action, dto.context); } @Get('instances/:id/actions') @RequirePermission('workflow.view') - async getAvailableActions( - @Param('id') instanceId: string, - @Query() context: WorkflowContext - ): Promise { + async getAvailableActions(@Param('id') instanceId: string, @Query() context: WorkflowContext): Promise { return this.workflowEngine.getAvailableActions(instanceId, context); } @Post('definitions') @RequirePermission('workflow.manage') - async createWorkflowDefinition( - @Body() dto: CreateWorkflowDefinitionDto - ): Promise { + async createWorkflowDefinition(@Body() dto: CreateWorkflowDefinitionDto): Promise { return this.workflowDslService.compileAndSave(dto.dslContent); } @Get('instances/:id/history') @RequirePermission('workflow.view') - async getWorkflowHistory( - @Param('id') instanceId: string - ): Promise { + async getWorkflowHistory(@Param('id') instanceId: string): Promise { return this.workflowHistoryService.getHistory(instanceId); } } @@ -1781,13 +1660,8 @@ export class CorrespondenceWorkflowService { private correspondenceService: CorrespondenceService ) {} - async submitCorrespondence( - correspondenceId: string, - userId: string - ): Promise { - const correspondence = await this.correspondenceService.findById( - correspondenceId - ); + async submitCorrespondence(correspondenceId: string, userId: string): Promise { + const correspondence = await this.correspondenceService.findById(correspondenceId); // Create workflow instance const instance = await this.workflowEngine.createInstance({ @@ -1824,11 +1698,7 @@ describe('WorkflowEngineService', () => { const context = { userId: 'user1', roles: ['APPROVER'] }; // Act - const result = await workflowEngine.processTransition( - instance.id, - 'APPROVE', - context - ); + const result = await workflowEngine.processTransition(instance.id, 'APPROVE', context); // Assert expect(result.success).toBe(true); @@ -1841,23 +1711,19 @@ describe('WorkflowEngineService', () => { const context = { userId: 'user2', roles: ['VIEWER'] }; // Act & Assert - await expect( - workflowEngine.processTransition(instance.id, 'APPROVE', context) - ).rejects.toThrow(WorkflowError); + await expect(workflowEngine.processTransition(instance.id, 'APPROVE', context)).rejects.toThrow(WorkflowError); }); }); }); ``` - **🔗 Critical Dependencies of T3.1.1-T3.1.8** - - T1.1 (Common Module) - สำหรับ base entities และ shared services - T1.4 (RBAC Guard) - สำหรับ permission checking - T2.5 (JSON Schema) - สำหรับ DSL validation - T6.2 (Notification) - สำหรับ event handling - **🎯 Success Metrics** - - ✅ Support ทั้ง Correspondence Routing และ RFA Workflow - ✅ DSL ที่ human-readable และ editable โดยไม่ต้องแก้โค้ด - ✅ Performance: < 50ms ต่อ state transition @@ -1865,7 +1731,6 @@ describe('WorkflowEngineService', () => { - ✅ Complete audit trail สำหรับทุก workflow instance - **[ ] T3.2 CorrespondenceModule - Basic CRUD** - - [ ] สร้าง Entities (Correspondence, Revision, Recipient, Tag, Reference, Attachment) - [ ] สร้าง CorrespondenceService (Create with Document Numbering, Update with new Revision, Soft Delete) - [ ] สร้าง Controllers (POST/GET/PUT/DELETE /correspondences) @@ -1875,7 +1740,6 @@ describe('WorkflowEngineService', () => { - [ ] **Dependencies:** T1.1, T1.2, T1.3, T1.4, T1.5, T2.3, T2.2, T2.5 - **[ ] T3.3 CorrespondenceModule - Advanced Features** - - [ ] Implement Status Transitions (DRAFT → SUBMITTED) - [ ] Implement References (Link Documents) - [ ] Implement Search (Basic) @@ -1884,7 +1748,6 @@ describe('WorkflowEngineService', () => { - [ ] **Dependencies:** T3.2 - **[ ] T3.4 Correspondence Integration with Workflow** - - [ ] เชื่อมต่อ `CorrespondenceService` เข้ากับ `WorkflowEngineModule` - [ ] ย้าย Logic การ Routing เดิมมาใช้ Engine ใหม่ - [ ] สร้าง API endpoints สำหรับ Frontend (Templates, Pending Tasks, Bulk Action) @@ -1901,7 +1764,6 @@ describe('WorkflowEngineService', () => { ### **Phase 4: Tasks** - **[ ] T4.1 DrawingModule - Contract Drawings** - - [ ] สร้าง Entities (ContractDrawing, Volume, Category, SubCategory, Attachment) - [ ] สร้าง ContractDrawingService CRUD - [ ] สร้าง Controllers (GET/POST /drawings/contract) @@ -1910,7 +1772,6 @@ describe('WorkflowEngineService', () => { - [ ] **Dependencies:** T1.1, T1.2, T1.4, T1.5, T2.2 - **[ ] T4.2 DrawingModule - Shop Drawings** - - [ ] สร้าง Entities (ShopDrawing, Revision, Main/SubCategory, ContractRef, RevisionAttachment) - [ ] สร้าง ShopDrawingService CRUD (รวมการสร้าง Revision) - [ ] สร้าง Controllers (GET/POST /drawings/shop, /drawings/shop/:id/revisions) @@ -1920,7 +1781,6 @@ describe('WorkflowEngineService', () => { - [ ] **Dependencies:** T4.1 - **[ ] T5.1 RfaModule with Unified Workflow** - - [ ] สร้าง Entities (Rfa, RfaRevision, RfaItem, RfaWorkflowTemplate/Step) - [ ] สร้าง RfaService (Create RFA, Link Shop Drawings) - [ ] Implement RFA Workflow โดยใช้ Configuration ของ `WorkflowEngineModule` @@ -1938,7 +1798,6 @@ describe('WorkflowEngineService', () => { ### **Phase 5: Tasks** - **[ ] T5.2 CirculationModule - Internal Routing** - - [ ] สร้าง Entities (Circulation, Template, Routing, Attachment) - [ ] สร้าง CirculationService (Create 1:1 with Correspondence, Assign User, Complete/Close Step) - [ ] สร้าง Controllers (POST/GET /circulations, POST /circulations/:id/steps/...) @@ -1947,7 +1806,6 @@ describe('WorkflowEngineService', () => { - [ ] **Dependencies:** T3.2, T2.5, T6.2 - **[ ] T5.3 TransmittalModule - Document Forwarding** - - [ ] สร้าง Entities (Transmittal, TransmittalItem) - [ ] สร้าง TransmittalService (Create Correspondence + Transmittal, Link Multiple Correspondences) - [ ] สร้าง Controllers (POST/GET /transmittals) @@ -1964,7 +1822,6 @@ describe('WorkflowEngineService', () => { ### **Phase 6: Tasks** - **[ ] T6.1 SearchModule - Elasticsearch Integration** - - [ ] Setup Elasticsearch Container - [ ] สร้าง SearchService (index/update/delete documents, search) - [ ] Index ทุก Document Type @@ -1974,7 +1831,6 @@ describe('WorkflowEngineService', () => { - [ ] **Dependencies:** T3.2, T5.1, T4.2, T5.2, T5.3 - **[ ] T6.2 Notification Queue & Digest** - - [ ] สร้าง NotificationService (sendEmail/Line/System) - [ ] **Producer:** Push Event ลง BullMQ Queue - [ ] **Consumer:** จัดกลุ่ม Notification (Digest Message) และส่งผ่าน Email/Line @@ -1985,7 +1841,6 @@ describe('WorkflowEngineService', () => { - [ ] **Dependencies:** T1.1, T6.4 - **[ ] T6.3 MonitoringModule - Observability** - - [ ] สร้าง Health Check Controller (GET /health) - [ ] สร้าง Metrics Service (API response times, Error rates) - [ ] สร้าง Performance Interceptor (Track request duration) @@ -1994,7 +1849,6 @@ describe('WorkflowEngineService', () => { - [ ] **Dependencies:** T1.1 - **[ ] T6.4 ResilienceModule - Circuit Breaker & Retry** - - [ ] สร้าง Circuit Breaker Service (@CircuitBreaker() decorator) - [ ] สร้าง Retry Service (@Retry() decorator) - [ ] สร้าง Fallback Strategies @@ -2003,7 +1857,6 @@ describe('WorkflowEngineService', () => { - [ ] **Dependencies:** T1.1 - **[ ] T6.5 Data Partitioning Strategy** - - [ ] ออกแบบ Table Partitioning สำหรับ `audit_logs` และ `notifications` (แบ่งตาม Range: Year) - [ ] เขียน Raw SQL Migration สำหรับสร้าง Partition Table - [ ] **Deliverable:** Database Performance และ Scalability ดีขึ้น @@ -2018,21 +1871,18 @@ describe('WorkflowEngineService', () => { ### **Phase 7: Tasks** - **[ ] T7.1 Concurrency Testing** - - [ ] เขียน Test Scenarios ยิง Request ขอเลขที่เอกสารพร้อมกัน 100 Request (ต้องไม่ซ้ำและไม่ข้าม) - [ ] ทดสอบ Optimistic Lock ทำงานถูกต้องเมื่อ Redis ถูกปิด - [ ] ทดสอบ File Upload พร้อมกันหลายไฟล์ - [ ] **Deliverable:** ระบบทนทานต่อ Concurrency Issues - **[ ] T7.2 Transaction Integrity Testing** - - [ ] ทดสอบ Upload ไฟล์แล้ว Kill Process ก่อน Commit - [ ] ทดสอบ Two-Phase File Storage ทำงานถูกต้อง - [ ] ทดสอบ Database Transaction Rollback Scenarios - [ ] **Deliverable:** Data Integrity รับประกันได้ - **[ ] T7.3 Security & Idempotency Test** - - [ ] ทดสอบ Replay Attack โดยใช้ `Idempotency-Key` ซ้ำ - [ ] ทดสอบ Maintenance Mode Block API ได้จริง - [ ] ทดสอบ RBAC 4-Level ทำงานถูกต้อง 100% @@ -2045,7 +1895,6 @@ describe('WorkflowEngineService', () => { - **[ ] T7.6 E2E Testing** - **[ ] T7.7 Performance Testing** - - [ ] Load Testing: 100 concurrent users - [ ] **(สำคัญ)** การจูนและทดสอบ Load Test จะต้องทำในสภาพแวดล้อมที่จำลอง Spec ของ QNAP Server (TS-473A, AMD Ryzen V1500B) เพื่อให้ได้ค่า Response Time และ Connection Pool ที่เที่ยงตรง - [ ] Stress Testing @@ -2053,14 +1902,12 @@ describe('WorkflowEngineService', () => { - [ ] **Deliverable:** Performance targets บรรลุ - **[ ] T7.8 Security Testing** - - [ ] Penetration Testing (OWASP Top 10) - [ ] Security Audit (Code review, Dependency scanning) - [ ] File Upload Security Testing - [ ] **Deliverable:** Security tests ผ่าน - **[ ] T7.9 Performance Optimization** - - [ ] Implement Caching (Master Data, User Permissions, Search Results) - [ ] Database Optimization (Review Indexes, Query Optimization, Pagination) - [ ] **Deliverable:** Response Time < 200ms (90th percentile) diff --git a/specs/99-archives/docs/Markdown/3_Frontend_Plan_V1_4_3.md b/specs/99-archives/docs/Markdown/3_Frontend_Plan_V1_4_3.md index 7d70f77..62bd0cd 100644 --- a/specs/99-archives/docs/Markdown/3_Frontend_Plan_V1_4_3.md +++ b/specs/99-archives/docs/Markdown/3_Frontend_Plan_V1_4_3.md @@ -925,8 +925,8 @@ F10_3 --> F10_4 ## 📊 **สรุป Timeline** -| Phase | ระยะเวลา | จำนวนงาน | Output หลัก | -| -------- | ------------ | ------------ | ------------------------------------ | +| Phase | ระยะเวลา | จำนวนงาน | Output หลัก | +| -------- | -------------- | ------------ | ------------------------------------ | | Phase 0 | 1 สัปดาห์ | 4 | Foundation & Tooling Ready | | Phase 1 | 1 สัปดาห์ | 4 | Core Application Structure | | Phase 2 | 1 สัปดาห์ | 4 | User Management & Security | diff --git a/specs/99-archives/docs/Markdown/3_Frontend_Plan_V1_4_4.md b/specs/99-archives/docs/Markdown/3_Frontend_Plan_V1_4_4.md index 730b7e6..0918c5c 100644 --- a/specs/99-archives/docs/Markdown/3_Frontend_Plan_V1_4_4.md +++ b/specs/99-archives/docs/Markdown/3_Frontend_Plan_V1_4_4.md @@ -206,7 +206,6 @@ ### **Phase 0: Tasks** - **[ ] F0.1 Project Setup & Tooling** - - [ ] Initialize Next.js 14+ project with TypeScript - [ ] Configure pnpm workspace - [ ] Setup ESLint, Prettier, and pre-commit hooks @@ -217,7 +216,6 @@ - [ ] **Dependencies:** None - **[ ] F0.2 Design System & UI Components** - - [ ] Setup color palette and design tokens - [ ] Create responsive design breakpoints - [ ] Implement core shadcn/ui components: @@ -233,7 +231,6 @@ - [ ] **Dependencies:** F0.1 - **[ ] F0.3 API Client & Authentication** - - [ ] Setup Axios client with interceptors: - [ ] Idempotency-Key header injection - [ ] Authentication token management @@ -282,7 +279,6 @@ ### **Phase 1: Tasks** - **[ ] F1.1 Main Layout & Navigation** - - [ ] Create App Shell layout: - [ ] Navbar with user menu and notifications - [ ] Collapsible sidebar with navigation @@ -299,7 +295,6 @@ - [ ] **Dependencies:** F0.2, F0.3 - **[ ] F1.2 Authentication Pages** - - [ ] Create login page with form validation - [ ] Implement forgot password flow - [ ] Create registration page (admin-only) @@ -310,7 +305,6 @@ - [ ] **Dependencies:** F0.3, F1.1 - **[ ] F1.3 Dashboard & Landing** - - [ ] Create public landing page for non-authenticated users - [ ] Implement main dashboard with: - [ ] KPI cards (document counts, pending tasks) @@ -356,7 +350,6 @@ ### **Phase 2: Tasks** - **[ ] F2.1 User Profile & Settings** - - [ ] Create user profile page: - [ ] Personal information display/edit - [ ] Password change functionality @@ -368,7 +361,6 @@ - [ ] **Dependencies:** F1.1, F0.4 - **[ ] F2.2 Admin Panel - User Management** - - [ ] Create user list with search and filters - [ ] Implement user creation form - [ ] Create user edit interface @@ -379,7 +371,6 @@ - [ ] **Dependencies:** F1.1, F2.1 - **[ ] F2.3 Admin Panel - Role Management** - - [ ] Create role list and management interface - [ ] Implement role creation and editing - [ ] Create permission assignment interface @@ -432,7 +423,6 @@ ### **Phase 3: Tasks** - **[ ] F3.1 Project Management UI** - - [ ] Create project list with search and filters - [ ] Implement project creation and editing - [ ] Create project detail view @@ -443,7 +433,6 @@ - [ ] **Dependencies:** F1.1, F2.4 - **[ ] F3.2 Organization Management** - - [ ] Create organization list and management - [ ] Implement organization creation and editing - [ ] Create organization detail view @@ -479,7 +468,6 @@ ### **Phase 4: Tasks** - **[ ] F4.1 Correspondence List & Search** - - [ ] Create correspondence list with advanced filtering: - [ ] Filter by type, status, project, organization - [ ] Search by title, document number, content @@ -494,7 +482,6 @@ - [ ] **Dependencies:** F1.1, F3.1 - **[ ] F4.2 Correspondence Creation Form** - - [ ] Create dynamic form generator based on JSON schema - [ ] Implement form with multiple sections: - [ ] Basic information (type, title, recipients) @@ -512,7 +499,6 @@ - [ ] **Dependencies:** F0.4, F4.1 - **[ ] F4.3 Correspondence Detail View** - - [ ] Create comprehensive detail page: - [ ] Document header with metadata - [ ] Content display based on type @@ -544,7 +530,6 @@ ### **Phase 4: Testing - Correspondence System** - **[ ] F4.T1 Correspondence Test Suite** - - [ ] **Unit Tests:** Form validation, file upload components - [ ] **Integration Tests:** Complete document lifecycle, file attachment flow - [ ] **E2E Tests:** End-to-end correspondence creation and management @@ -563,7 +548,6 @@ ### **Phase 5: Tasks** - **[ ] F5.1 Workflow Visualization Component** - - [ ] Create horizontal workflow progress visualization - [ ] Implement step status indicators (pending, active, completed, skipped) - [ ] Add due date and assignee information @@ -574,7 +558,6 @@ - [ ] **Dependencies:** F4.3 - **[ ] F5.2 Routing Template Management** - - [ ] Create routing template list and editor - [ ] Implement drag-and-drop step configuration - [ ] Add step configuration (purpose, duration, assignee rules) @@ -585,7 +568,6 @@ - [ ] **Dependencies:** F3.1, F4.2 - **[ ] F5.3 Workflow Step Actions** - - [ ] Create step action interface: - [ ] Approve, reject, request changes - [ ] Add comments and attachments @@ -623,7 +605,6 @@ ### **Phase 6: Tasks** - **[ ] F6.1 Contract Drawings Management** - - [ ] Create contract drawing list with categorization - [ ] Implement drawing upload and metadata management - [ ] Create drawing preview and viewer @@ -634,7 +615,6 @@ - [ ] **Dependencies:** F3.1, F4.4 - **[ ] F6.2 Shop Drawings Management** - - [ ] Create shop drawing list with revision tracking - [ ] Implement shop drawing creation and revision system - [ ] Create drawing comparison interface @@ -645,7 +625,6 @@ - [ ] **Dependencies:** F6.1 - **[ ] F6.3 Drawing Revision System** - - [ ] Create revision history interface - [ ] Implement revision comparison functionality - [ ] Add revision notes and change tracking @@ -681,7 +660,6 @@ ### **Phase 7: Tasks** - **[ ] F7.1 RFA List & Dashboard** - - [ ] Create RFA dashboard with status overview - [ ] Implement advanced RFA filtering and search - [ ] Create RFA calendar view for deadlines @@ -692,7 +670,6 @@ - [ ] **Dependencies:** F4.1, F5.1 - **[ ] F7.2 RFA Creation with Dynamic Forms** - - [ ] Create RFA type-specific form generator - [ ] Implement dynamic form fields based on RFA type: - [ ] RFA_DWG: Shop drawing selection @@ -712,7 +689,6 @@ - [ ] **Dependencies:** F4.2, F6.2 - **[ ] F7.3 RFA Workflow Integration** - - [ ] Integrate RFA with unified workflow engine - [ ] Create RFA-specific workflow steps and actions - [ ] Implement RFA approval interface @@ -748,7 +724,6 @@ ### **Phase 8: Tasks** - **[ ] F8.1 Circulation Management** - - [ ] Create circulation list and management interface - [ ] Implement circulation creation from correspondence - [ ] Create circulation template management @@ -759,7 +734,6 @@ - [ ] **Dependencies:** F4.1, F5.2 - **[ ] F8.2 Task Assignment Interface** - - [ ] Create task assignment interface with user selection - [ ] Implement task priority and deadline setting - [ ] Add task dependency management @@ -795,7 +769,6 @@ ### **Phase 9: Tasks** - **[ ] F9.1 Advanced Search Interface** - - [ ] Create unified search interface across all document types - [ ] Implement faceted search with multiple filters - [ ] Add search result highlighting and relevance scoring @@ -806,7 +779,6 @@ - [ ] **Dependencies:** F4.1, F7.1 - **[ ] F9.2 Notification System** - - [ ] Create notification center with real-time updates - [ ] Implement notification preferences management - [ ] Add notification grouping and digest views @@ -817,7 +789,6 @@ - [ ] **Dependencies:** F1.3, F5.4 - **[ ] F9.3 Reporting & Analytics** - - [ ] Create reporting dashboard with customizable widgets - [ ] Implement data visualization components (charts, graphs) - [ ] Add report scheduling and export @@ -853,7 +824,6 @@ ### **Phase 10: Tasks** - **[ ] F10.1 Comprehensive Testing** - - [ ] Idempotency Testing: เพิ่มการทดสอบเฉพาะสำหรับ Axios Interceptor เพื่อจำลองการส่ง Request POST/PUT/DELETE ที่มี Idempotency-Key ซ้ำไปยัง Mock API (MSW) เพื่อยืนยันว่า Client-side ไม่ส่ง Key ซ้ำในการทำงานปกติ และไม่เกิด Side Effect จากการ Replay Attack. - [ ] Write unit tests for all components and utilities - [ ] Create integration tests for critical user flows @@ -865,7 +835,6 @@ - [ ] **Dependencies:** All previous phases - **[ ] F10.2 Performance Optimization** - - [ ] Implement code splitting and lazy loading - [ ] Optimize bundle size and asset delivery - [ ] Add performance monitoring and metrics @@ -876,7 +845,6 @@ - [ ] **Dependencies:** F10.1 - **[ ] F10.3 Security Hardening** - - [ ] Conduct security audit and penetration testing - [ ] Implement Content Security Policy (CSP) - [ ] Add security headers and protections diff --git a/specs/99-archives/docs/Markdown/4_Data_Dictionary_V1_4_3.md b/specs/99-archives/docs/Markdown/4_Data_Dictionary_V1_4_3.md index cc2fa0b..e41779a 100644 --- a/specs/99-archives/docs/Markdown/4_Data_Dictionary_V1_4_3.md +++ b/specs/99-archives/docs/Markdown/4_Data_Dictionary_V1_4_3.md @@ -474,26 +474,26 @@ **Purpose**: Child table storing revision history of correspondences (1:N) -| Column Name | Data Type | Constraints | Description | -| ------------------------ | ------------ | --------------------------------- | -------------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | -| correspondence_id | INT | NOT NULL, FK | Master correspondence ID | -| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | -| revision_label | VARCHAR(10) | NULL | Display revision (A, B, 1.1...) | -| is_current | BOOLEAN | DEFAULT FALSE | Current revision flag | -| correspondence_status_id | INT | NOT NULL, FK | Current status of this revision | -| title | VARCHAR(255) | NOT NULL | Document title | -| document_date | DATE | NULL | Document date | -| issued_date | DATETIME | NULL | Issue date | -| received_date | DATETIME | NULL | Received date | -| due_date | DATETIME | NULL | Due date for response | -| description | TEXT | NULL | Revision description | -| details | JSON | NULL | Type-specific details (e.g., RFI questions) | -| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | -| created_by | INT | NULL, FK | User who created revision | -| updated_by | INT | NULL, FK | User who last updated | +| Column Name | Data Type | Constraints | Description | +| ------------------------ | ------------ | --------------------------------- | ------------------------------------------------------------ | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | +| correspondence_id | INT | NOT NULL, FK | Master correspondence ID | +| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | +| revision_label | VARCHAR(10) | NULL | Display revision (A, B, 1.1...) | +| is_current | BOOLEAN | DEFAULT FALSE | Current revision flag | +| correspondence_status_id | INT | NOT NULL, FK | Current status of this revision | +| title | VARCHAR(255) | NOT NULL | Document title | +| document_date | DATE | NULL | Document date | +| issued_date | DATETIME | NULL | Issue date | +| received_date | DATETIME | NULL | Received date | +| due_date | DATETIME | NULL | Due date for response | +| description | TEXT | NULL | Revision description | +| details | JSON | NULL | Type-specific details (e.g., RFI questions) | +| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | +| created_by | INT | NULL, FK | User who created revision | +| updated_by | INT | NULL, FK | User who last updated | | v_ref_project_id | INT | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Project ID จาก JSON details เพื่อทำ Index | -| v_ref_type | VARCHAR(50) | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Type จาก JSON details | +| v_ref_type | VARCHAR(50) | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Type จาก JSON details | **Indexes**: @@ -627,16 +627,16 @@ **Purpose**: เก็บข้อมูลแม่แบบ (Template) ของสายงานการส่งต่อเอกสารเพื่อขออนุมัติ ทำให้ไม่ต้องกำหนดขั้นตอนซ้ำทุกครั้ง สามารถสร้างเป็นแม่แบบทั่วไป หรือเฉพาะสำหรับโครงการใดโครงการหนึ่งได้ -| Column Name | Data Type | Constraints | Description | -| --------------- | ------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | ID หลัก (Primary Key) ของแม่แบบ รันค่าอัตโนมัติ | -| template_name | VARCHAR(255) | NOT NULL | ชื่อของแม่แบบ เช่น "เสนอโครงการ", "ขออนุมัติจัดซื้อ" | -| description | TEXT | NULL | คำอธิบายรายละเอียดเกี่ยวกับแม่แบบนี้ | +| Column Name | Data Type | Constraints | Description | +| --------------- | ------------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | ID หลัก (Primary Key) ของแม่แบบ รันค่าอัตโนมัติ | +| template_name | VARCHAR(255) | NOT NULL | ชื่อของแม่แบบ เช่น "เสนอโครงการ", "ขออนุมัติจัดซื้อ" | +| description | TEXT | NULL | คำอธิบายรายละเอียดเกี่ยวกับแม่แบบนี้ | | project_id | INT | NULL | ID ของโครงการที่แม่แบบนี้สังกัดอยู่ (ถ้ามี) **ค่า NULL หมายถึง** เป็น "แม่แบบทั่วไป" ที่สามารถใช้กับทุกโครงการได้ | -| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | วันที่และเวลาที่สร้างแม่แบบนี้ | -| updated_at | TIMESTAMP | NOT NULL,`DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | วันที่และเวลาที่แก้ไขข้อมูลในแม่แบบนี้ล่าสุด | -| is_active | BOOLEAN | DEFAULT TRUE | สถานะใช้งาน | -| workflow_config | JSON | NULL | เก็บ State Machine Configuration หรือ Rules เพิ่มเติมที่ซับซ้อนกว่า Column ปกติ | +| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | วันที่และเวลาที่สร้างแม่แบบนี้ | +| updated_at | TIMESTAMP | NOT NULL,`DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | วันที่และเวลาที่แก้ไขข้อมูลในแม่แบบนี้ล่าสุด | +| is_active | BOOLEAN | DEFAULT TRUE | สถานะใช้งาน | +| workflow_config | JSON | NULL | เก็บ State Machine Configuration หรือ Rules เพิ่มเติมที่ซับซ้อนกว่า Column ปกติ | **Indexes**: @@ -653,14 +653,14 @@ **Purpose**: เก็บรายละเอียดของแต่ละขั้นตอน (Steps) ภายในแม่แบบสายงาน (correspondence_routing_templates) กำหนดว่าจะส่งไปที่องค์กรไหน ลำดับเป็นเท่าไร และเพื่อวัตถุประสงค์อะไร -| Column Name | Data Type | Constraints | Description | -| :----------------- | --------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | ID หลักของขั้นตอน | -| template_id | INT | NOT NULL | ID ของแม่แบบที่ขั้นตอนนี้สังกัดอยู่ | -| sequence | INT | NOT NULL | ลำดับของขั้นตอน (1, 2, 3, ...) | -| to_organization_id | INT | NOT NULL | ID ขององค์กรที่เป็นผู้รับในขั้นตอนนี้ | +| Column Name | Data Type | Constraints | Description | +| :----------------- | --------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | ID หลักของขั้นตอน | +| template_id | INT | NOT NULL | ID ของแม่แบบที่ขั้นตอนนี้สังกัดอยู่ | +| sequence | INT | NOT NULL | ลำดับของขั้นตอน (1, 2, 3, ...) | +| to_organization_id | INT | NOT NULL | ID ขององค์กรที่เป็นผู้รับในขั้นตอนนี้ | | step_purpose | ENUM | NOT NULL,DEFAULT FOR_REVIEW | วัตถุประสงค์ของการส่งต่อในขั้นตอนนี้ **ค่าที่เป็นไปได้:** [FOR_APPROVAL: เพื่ออนุมัติ, FOR_REVIEW: เพื่อตรวจสอบ/พิจารณา, FOR_INFORMATION: เพื่อทราบ] | -| expected_days | INT | NULL | วันที่คาดหวัง | +| expected_days | INT | NULL | วันที่คาดหวัง | **Indexes**: @@ -679,22 +679,22 @@ **Purpose**: เป็นตารางที่เก็บข้อมูลการส่งต่อเอกสารจริง (Instance/Run-time) ติดตามประวัติการเคลื่อนย้ายของแต่ละเอกสาร ว่าผ่านใครมาบ้าง อยู่ที่ใคร และสถานะปัจจุบันคืออะไร -| Column Name | Data Type | Constraints | Description | -| -------------------- | --------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | ID หลักของรายการส่งต่อ | -| correspondence_id | INT | NOT NUL | ID ของเอกสาร (FK ไปยัง correspondence_revisions) | -| template_id | INT | NULL | ID ของแม่แบบที่ใช้สร้างสายงานนี้ (เก็บไว้เป็นข้อมูลอ้างอิง) | -| sequence | INT | NOT NULL | ลำดับของขั้นตอนการส่งต่อจริง | -| from_organization_id | INT | NOT NULL | ID ขององค์กรผู้ส่ง | -| to_organization_id | INT | NOT NULL | ID ขององค์กรผู้รับ | -| step_purpose | ENUM | NOT NULL, DEFAULT FOR_REVIEW | วัตถุประสงค์ของการส่งต่อในขั้นตอนนี้จริง **ค่าที่เป็นไปได้:** [FOR_APPROVAL: เพื่ออนุมัติ, FOR_REVIEW: เพื่อตรวจสอบ/พิจารณา, FOR_INFORMATION: เพื่อทราบ, FOR_ACTION: เพื่อดำเนินการ] | -| status | ENUM | NOT NULL, DEFAULT SENT | [ACTIONED: ดำเนินการแล้ว, FORWARDED: ส่งต่อแล้ว, REPLIE: ตอบกลับแล้ว] | -| comments | TEXT | NULL | หมายเหตุหรือความคิดเห็นในการส่งต่อ | -| due_date | DATETIME | NULL | วันที่ครบกำหนดที่ต้องดำเนินการในขั้นตอนนี้ | -| processed_by_user_id | INT | NULL | ID ของผู้ใช้ที่ดำเนินการในขั้นตอนนี้จริงๆ | -| processed_at | TIMESTAMP | NULL | เวลาที่ผู้ใช้ดำเนินการเสร็จสิ้น | -| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | เวลาที่สร้างรายการส่งต่อนี้ | -| state_context | JSON | NULL | เก็บข้อมูล Context ของ Workflow ณ ขณะนั้น (Snapshot) | +| Column Name | Data Type | Constraints | Description | +| -------------------- | --------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | ID หลักของรายการส่งต่อ | +| correspondence_id | INT | NOT NUL | ID ของเอกสาร (FK ไปยัง correspondence_revisions) | +| template_id | INT | NULL | ID ของแม่แบบที่ใช้สร้างสายงานนี้ (เก็บไว้เป็นข้อมูลอ้างอิง) | +| sequence | INT | NOT NULL | ลำดับของขั้นตอนการส่งต่อจริง | +| from_organization_id | INT | NOT NULL | ID ขององค์กรผู้ส่ง | +| to_organization_id | INT | NOT NULL | ID ขององค์กรผู้รับ | +| step_purpose | ENUM | NOT NULL, DEFAULT FOR_REVIEW | วัตถุประสงค์ของการส่งต่อในขั้นตอนนี้จริง **ค่าที่เป็นไปได้:** [FOR_APPROVAL: เพื่ออนุมัติ, FOR_REVIEW: เพื่อตรวจสอบ/พิจารณา, FOR_INFORMATION: เพื่อทราบ, FOR_ACTION: เพื่อดำเนินการ] | +| status | ENUM | NOT NULL, DEFAULT SENT | [ACTIONED: ดำเนินการแล้ว, FORWARDED: ส่งต่อแล้ว, REPLIE: ตอบกลับแล้ว] | +| comments | TEXT | NULL | หมายเหตุหรือความคิดเห็นในการส่งต่อ | +| due_date | DATETIME | NULL | วันที่ครบกำหนดที่ต้องดำเนินการในขั้นตอนนี้ | +| processed_by_user_id | INT | NULL | ID ของผู้ใช้ที่ดำเนินการในขั้นตอนนี้จริงๆ | +| processed_at | TIMESTAMP | NULL | เวลาที่ผู้ใช้ดำเนินการเสร็จสิ้น | +| created_at | TIMESTAMP | NOT NULL, DEFAULT CURRENT_TIMESTAMP | เวลาที่สร้างรายการส่งต่อนี้ | +| state_context | JSON | NULL | เก็บข้อมูล Context ของ Workflow ณ ขณะนั้น (Snapshot) | **Indexes**: @@ -719,11 +719,11 @@ **Purpose**: ตารางนี้ใช้กำหนดกฎ (State Machine) ว่าสถานะใดสามารถเปลี่ยนไปเป็นสถานะใดได้บ้าง โดยขึ้นอยู่กับประเภทของหนังสือ เพื่อควบคุมการไหลของสถานะให้ถูกต้องตามข้อบังคับ -| Column Name | Data Type | Constraints | Description | -| -------------- | --------- | ----------- | ----------------------------------------------- | +| Column Name | Data Type | Constraints | Description | +| -------------- | --------- | ----------- | ------------------------------------------------------ | | type_id | INT | PRIMARY KEY | ID ของประเภทหนังสือ (เช่น หนังสือภายใน, หนังสือภายนอก) | -| from_status_id | INT | PRIMARY KEY | ID ของสถานะต้นทาง (เช่น ร่าง) | -| to_status_id | INT | PRIMARY KEY | ID ของสถานะปลายทาง (เช่น รออนุมัติ) | +| from_status_id | INT | PRIMARY KEY | ID ของสถานะต้นทาง (เช่น ร่าง) | +| to_status_id | INT | PRIMARY KEY | ID ของสถานะปลายทาง (เช่น รออนุมัติ) | **คีย์หลัก (Primary Key):** @@ -957,14 +957,14 @@ **Purpose**: Master table for RFA approval workflow templates -| Column Name | Data Type | Constraints | Description | -| --------------- | ------------ | ----------------------------------- | -------------------------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique template ID | -| template_name | VARCHAR(100) | NOT NULL | Template name | -| description | TEXT | NULL | Template description | -| is_active | TINYINT(1) | DEFAULT 1 | Active status | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | +| Column Name | Data Type | Constraints | Description | +| --------------- | ------------ | ----------------------------------- | ------------------------------------------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique template ID | +| template_name | VARCHAR(100) | NOT NULL | Template name | +| description | TEXT | NULL | Template description | +| is_active | TINYINT(1) | DEFAULT 1 | Active status | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | | workflow_config | JSON | NULL | เก็บ State Machine Configuration หรือ Rules เพิ่มเติมที่ซับซ้อนกว่า Column ปกติ | **Indexes**: @@ -1024,20 +1024,20 @@ **Purpose**: Transaction log table tracking actual RFA approval workflow execution -| Column Name | Data Type | Constraints | Description | -| --------------- | --------- | ----------------------------------- | ------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique workflow log ID | -| rfa_revision_id | INT | NOT NULL, FK | Reference to RFA revision | -| step_number | INT | NOT NULL | Current step number | -| organization_id | INT | NOT NULL, FK | Organization responsible | -| assigned_to | INT | NULL, FK | Assigned user ID | -| action_type | ENUM | NULL | Action type: REVIEW, APPROVE, ACKNOWLEDGE | -| status | ENUM | NULL | Status: PENDING, IN_PROGRESS, COMPLETED, REJECTED | -| comments | TEXT | NULL | Comments/remarks | -| completed_at | DATETIME | NULL | Completion timestamp | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | -| state_context | JSON* | NULL | เก็บข้อมูล Context ของ Workflow ณ ขณะนั้น (Snapshot) | +| Column Name | Data Type | Constraints | Description | +| --------------- | --------- | ----------------------------------- | ---------------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique workflow log ID | +| rfa_revision_id | INT | NOT NULL, FK | Reference to RFA revision | +| step_number | INT | NOT NULL | Current step number | +| organization_id | INT | NOT NULL, FK | Organization responsible | +| assigned_to | INT | NULL, FK | Assigned user ID | +| action_type | ENUM | NULL | Action type: REVIEW, APPROVE, ACKNOWLEDGE | +| status | ENUM | NULL | Status: PENDING, IN_PROGRESS, COMPLETED, REJECTED | +| comments | TEXT | NULL | Comments/remarks | +| completed_at | DATETIME | NULL | Completion timestamp | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | +| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | +| state_context | JSON\* | NULL | เก็บข้อมูล Context ของ Workflow ณ ขณะนั้น (Snapshot) | **Indexes**: @@ -1673,20 +1673,20 @@ **Purpose**: Central repository for all file attachments in the system -| Column Name | Data Type | Constraints | Description | -| ------------------- | ------------ | --------------------------- | -------------------------------------------------------------- | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique attachment ID | -| original_filename | VARCHAR(255) | NOT NULL | Original filename from upload | -| stored_filename | VARCHAR(255) | NOT NULL | System-generated unique filename | -| file_path | VARCHAR(500) | NOT NULL | Full file path on server (/share/dms-data/) | -| mime_type | VARCHAR(100) | NOT NULL | MIME type (application/pdf, image/jpeg, etc.) | -| file_size | INT | NOT NULL | File size in bytes | -| uploaded_by_user_id | INT | NOT NULL, FK | User who uploaded file | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Upload timestamp | -| is_temporary | BOOLEAN | DEFAULT TRUE | ระบุว่าเป็นไฟล์ชั่วคราว (ยังไม่ได้ Commit) | -| temp_id* | VARCHAR(100) | NULL | ID ชั่วคราวสำหรับอ้างอิงตอน Upload Phase 1 (อาจใช้ร่วมกับ id หรือแยกก็ได้) | -| expires_at | DATETIME | NULL | เวลาหมดอายุของไฟล์ Temp (เพื่อให้ Cron Job ลบออก) | -| checksum | VARCHAR(64) | NULL | SHA-256 Checksum สำหรับ Verify File Integrity [Req 3.9.3] | +| Column Name | Data Type | Constraints | Description | +| ------------------- | ------------ | --------------------------- | -------------------------------------------------------------------------- | +| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique attachment ID | +| original_filename | VARCHAR(255) | NOT NULL | Original filename from upload | +| stored_filename | VARCHAR(255) | NOT NULL | System-generated unique filename | +| file_path | VARCHAR(500) | NOT NULL | Full file path on server (/share/dms-data/) | +| mime_type | VARCHAR(100) | NOT NULL | MIME type (application/pdf, image/jpeg, etc.) | +| file_size | INT | NOT NULL | File size in bytes | +| uploaded_by_user_id | INT | NOT NULL, FK | User who uploaded file | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Upload timestamp | +| is_temporary | BOOLEAN | DEFAULT TRUE | ระบุว่าเป็นไฟล์ชั่วคราว (ยังไม่ได้ Commit) | +| temp_id\* | VARCHAR(100) | NULL | ID ชั่วคราวสำหรับอ้างอิงตอน Upload Phase 1 (อาจใช้ร่วมกับ id หรือแยกก็ได้) | +| expires_at | DATETIME | NULL | เวลาหมดอายุของไฟล์ Temp (เพื่อให้ Cron Job ลบออก) | +| checksum | VARCHAR(64) | NULL | SHA-256 Checksum สำหรับ Verify File Integrity [Req 3.9.3] | **Indexes**: @@ -1865,14 +1865,14 @@ **Purpose**: Transaction table maintaining running sequence numbers for document numbering -| Column Name | Data Type | Constraints | Description | -| -------------------------- | --------- | --------------- | ----------------------------------------------- | -| project_id | INT | PRIMARY KEY, FK | Reference to projects | -| originator_organization_id | INT | PRIMARY KEY, FK | Originating organization | -| correspondence_type_id | INT | PRIMARY KEY, FK | Reference to correspondence types | -| current_year | INT | PRIMARY KEY | Year (Buddhist calendar) | +| Column Name | Data Type | Constraints | Description | +| -------------------------- | --------- | --------------- | ---------------------------------------------------- | +| project_id | INT | PRIMARY KEY, FK | Reference to projects | +| originator_organization_id | INT | PRIMARY KEY, FK | Originating organization | +| correspondence_type_id | INT | PRIMARY KEY, FK | Reference to correspondence types | +| current_year | INT | PRIMARY KEY | Year (Buddhist calendar) | | version | INT | DEFAULT 0 | ใช้สำหรับ Optimistic Locking (ตรวจสอบค่าก่อน Update) | -| last_number | INT | DEFAULT 0 | Last assigned sequence number | +| last_number | INT | DEFAULT 0 | Last assigned sequence number | **Indexes**: @@ -1905,20 +1905,20 @@ **Purpose**: Comprehensive audit trail for all significant system actions -| Column Name | Data Type | Constraints | Description | -| ---------------- | ----------------------------------------- | --------------------------------- | -------------------------------------------------------- | -| audit_id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | Unique audit log ID | -| user_id | INT | NULL, FK | User who performed action | -| action | VARCHAR(100) | NOT NULL | Action code (e.g., 'rfa.create', 'login.success') | -| entity_type | VARCHAR(50) | NULL | Entity/module affected (e.g., 'rfa', 'correspondence') | -| entity_id | VARCHAR(50) | NULL | Primary ID of affected record | -| details_json | JSON | NULL | Additional context/details in JSON format | -| ip_address | VARCHAR(45) | NULL | Client IP address (supports IPv6) | -| user_agent | VARCHAR(255) | NULL | Browser user agent string | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Action timestamp | +| Column Name | Data Type | Constraints | Description | +| ---------------- | ----------------------------------------- | --------------------------------- | ------------------------------------------------------------ | +| audit_id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | Unique audit log ID | +| user_id | INT | NULL, FK | User who performed action | +| action | VARCHAR(100) | NOT NULL | Action code (e.g., 'rfa.create', 'login.success') | +| entity_type | VARCHAR(50) | NULL | Entity/module affected (e.g., 'rfa', 'correspondence') | +| entity_id | VARCHAR(50) | NULL | Primary ID of affected record | +| details_json | JSON | NULL | Additional context/details in JSON format | +| ip_address | VARCHAR(45) | NULL | Client IP address (supports IPv6) | +| user_agent | VARCHAR(255) | NULL | Browser user agent string | +| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Action timestamp | | v_ref_project_id | INT | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Project ID จาก JSON details เพื่อทำ Index | -| v_ref_type | VARCHAR(50) | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Type จาก JSON details | -| request_id | VARCHAR(100) | NULL | Request ID/Trace ID เพื่อเชื่อมโยงกับ App Logs | +| v_ref_type | VARCHAR(50) | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Type จาก JSON details | +| request_id | VARCHAR(100) | NULL | Request ID/Trace ID เพื่อเชื่อมโยงกับ App Logs | | severity | ENUM('INFO', 'WARN', 'ERROR', 'CRITICAL') | NULL | ระดับความรุนแรงของเหตุการณ์ | **Indexes**: @@ -2088,27 +2088,27 @@ **Purpose**: องรับ **Centralized JSON Schema Registry** เพื่อ Validate ข้อมูล JSON Details ของเอกสารแต่ละประเภท ตาม Requirements 6.11.1 และ Backend Plan T2.5.1 -| Column Name | Data Type | Constraints | Description | -| :-------------------- | :------------- | :--------------- | :------------------------------------------------- | -| **id** | `INT` | PK, AI | Unique Identifier | -| **schema_code** | `VARCHAR(100)` | UNIQUE, NOT NULL | รหัส Schema (เช่น `RFA_DWG_V1`, `CORR_RFI_V1`) | -| **version** | `INT` | NOT NULL | เวอร์ชันของ Schema | +| Column Name | Data Type | Constraints | Description | +| :-------------------- | :------------- | :--------------- | :-------------------------------------------------- | +| **id** | `INT` | PK, AI | Unique Identifier | +| **schema_code** | `VARCHAR(100)` | UNIQUE, NOT NULL | รหัส Schema (เช่น `RFA_DWG_V1`, `CORR_RFI_V1`) | +| **version** | `INT` | NOT NULL | เวอร์ชันของ Schema | | **schema_definition** | `JSON` | NOT NULL | โครงสร้าง JSON Schema (Standard JSON Schema format) | | **is_active** | `BOOLEAN` | DEFAULT TRUE | สถานะการใช้งาน | -| **created_at** | `TIMESTAMP` | | วันที่สร้าง | +| **created_at** | `TIMESTAMP` | | วันที่สร้าง | ### 10.6 user_preferences **Purpose**: แยกข้อมูลการตั้งค่าส่วนตัว (เช่น Notification Settings) ออกจากตาราง Users เพื่อความยืดหยุ่น ตาม Requirements 5.5 และ 6.8.3 -| Column Name | Data Type | Constraints | Description | -| :--------------- | :------------ | :-------------- | :------------------------------------- | -| **user_id** | `INT` | PK, FK | อ้างอิงตาราง users | -| **notify_email** | `BOOLEAN` | DEFAULT TRUE | รับแจ้งเตือนทางอีเมล | +| Column Name | Data Type | Constraints | Description | +| :--------------- | :------------ | :-------------- | :---------------------------------------- | +| **user_id** | `INT` | PK, FK | อ้างอิงตาราง users | +| **notify_email** | `BOOLEAN` | DEFAULT TRUE | รับแจ้งเตือนทางอีเมล | | **notify_line** | `BOOLEAN` | DEFAULT TRUE | รับแจ้งเตือนทาง LINE | | **digest_mode** | `BOOLEAN` | DEFAULT TRUE | รับแจ้งเตือนแบบรวม (Digest) แทน Real-time | -| **ui_theme** | `VARCHAR(20)` | DEFAULT 'light' | ธีมหน้าจอ (Light/Dark) | -| **updated_at** | `TIMESTAMP` | | วันที่แก้ไขล่าสุด | +| **ui_theme** | `VARCHAR(20)` | DEFAULT 'light' | ธีมหน้าจอ (Light/Dark) | +| **updated_at** | `TIMESTAMP` | | วันที่แก้ไขล่าสุด | ## **11. 📊 Views & Procedures (วิว และ โปรซีเดอร์)** @@ -2326,28 +2326,23 @@ WHERE user_id = ? **Additional Performance Indexes**: 1. **Correspondence Tables**: - - `idx_correspondences_type_project` on (correspondence_type_id, project_id) - `idx_corr_revisions_current_status` on (is_current, correspondence_status_id) - `idx_corr_revisions_correspondence_current` on (correspondence_id, is_current) - `idx_correspondences_project_type` on (project_id, correspondence_type_id) 2. **RFA Tables**: - - `idx_rfa_revisions_current_status` on (is_current, rfa_status_code_id) - `idx_rfa_revisions_rfa_current` on (rfa_id, is_current) 3. **Circulation Tables**: - - `idx_circulation_routings_status_assigned` on (status, assigned_to) - `idx_circulation_routings_circulation_status` on (circulation_id, status) 4. **Document Numbering**: - - `idx_doc_counter_composite` on (project_id, originator_organization_id, correspondence_type_id, current_year) 5. **Audit & Notifications**: - - `idx_audit_logs_reporting` on (created_at, entity_type, action) - `idx_notifications_user_unread` on (user_id, is_read, created_at) @@ -2375,12 +2370,10 @@ WHERE user_id = ? ### Unique Constraints 1. **Globally Unique**: - - usernames, emails - shop_drawing.drawing_number 2. **Unique Within Scope**: - - (project_id, correspondence_number) - (project_id, condwg_no) - (correspondence_id, revision_number) @@ -2399,13 +2392,11 @@ WHERE user_id = ? ### Business Rule Constraints 1. **Soft Delete Pattern**: - - deleted_at timestamp instead of hard delete - Preserves audit trail and relationships - Applied to: correspondences, rfas, shop_drawings, contract_drawings 2. **Current Revision Pattern**: - - is_current flag with UNIQUE constraint - Ensures only one current revision per document @@ -2590,13 +2581,11 @@ ANALYZE TABLE correspondences; ### Business Logic Validation 1. **Document Workflow**: - - Cannot edit submitted documents (unless Document Control) - Cannot skip workflow steps (unless forced) - Must provide approval comments 2. **User Management**: - - Cannot delete users with active assignments - Cannot deactivate own account - Must have valid organization for non-Global roles @@ -2712,19 +2701,16 @@ ANALYZE TABLE correspondences; ### Integration Points 1. **Document Numbering**: - - Call DocumentNumberingService.generateNextNumber() (NestJS) which handles Redis locking and retry logic - Format with template from document_number_formats - Store in correspondences.correspondence_number 2. **File Upload**: - - Upload to QNAP /share/dms-data/ - Create attachment record - Link via junction table 3. **Workflow Execution**: - - Check rfa_workflow_templates - Create rfa_workflows records - Update status as steps complete diff --git a/specs/99-archives/docs/Markdown/4_Data_Dictionary_V1_4_4.md b/specs/99-archives/docs/Markdown/4_Data_Dictionary_V1_4_4.md index 62d5d49..e4cece9 100644 --- a/specs/99-archives/docs/Markdown/4_Data_Dictionary_V1_4_4.md +++ b/specs/99-archives/docs/Markdown/4_Data_Dictionary_V1_4_4.md @@ -2409,28 +2409,23 @@ WHERE user_id = ? **Additional Performance Indexes**: 1. **Correspondence Tables**: - - `idx_correspondences_type_project` on (correspondence_type_id, project_id) - `idx_corr_revisions_current_status` on (is_current, correspondence_status_id) - `idx_corr_revisions_correspondence_current` on (correspondence_id, is_current) - `idx_correspondences_project_type` on (project_id, correspondence_type_id) 2. **RFA Tables**: - - `idx_rfa_revisions_current_status` on (is_current, rfa_status_code_id) - `idx_rfa_revisions_rfa_current` on (rfa_id, is_current) 3. **Circulation Tables**: - - `idx_circulation_routings_status_assigned` on (status, assigned_to) - `idx_circulation_routings_circulation_status` on (circulation_id, status) 4. **Document Numbering**: - - `idx_doc_counter_composite` on (project_id, originator_organization_id, correspondence_type_id, current_year) 5. **Audit & Notifications**: - - `idx_audit_logs_reporting` on (created_at, entity_type, action) - `idx_notifications_user_unread` on (user_id, is_read, created_at) @@ -2458,12 +2453,10 @@ WHERE user_id = ? ### Unique Constraints 1. **Globally Unique**: - - usernames, emails - shop_drawing.drawing_number 2. **Unique Within Scope**: - - (project_id, correspondence_number) - (project_id, condwg_no) - (correspondence_id, revision_number) @@ -2482,13 +2475,11 @@ WHERE user_id = ? ### Business Rule Constraints 1. **Soft Delete Pattern**: - - deleted_at timestamp instead of hard delete - Preserves audit trail and relationships - Applied to: correspondences, rfas, shop_drawings, contract_drawings 2. **Current Revision Pattern**: - - is_current flag with UNIQUE constraint - Ensures only one current revision per document @@ -2673,13 +2664,11 @@ ANALYZE TABLE correspondences; ### Business Logic Validation 1. **Document Workflow**: - - Cannot edit submitted documents (unless Document Control) - Cannot skip workflow steps (unless forced) - Must provide approval comments 2. **User Management**: - - Cannot delete users with active assignments - Cannot deactivate own account - Must have valid organization for non-Global roles @@ -2795,19 +2784,16 @@ ANALYZE TABLE correspondences; ### Integration Points 1. **Document Numbering**: - - Call DocumentNumberingService.generateNextNumber() (NestJS) which handles Redis locking and retry logic - Format with template from document_number_formats - Store in correspondences.correspondence_number 2. **File Upload**: - - Upload to QNAP /share/dms-data/ - Create attachment record - Link via junction table 3. **Workflow Execution**: - - Check rfa_workflow_templates - Create rfa_workflows records - Update status as steps complete diff --git a/specs/99-archives/docs/Markdown/FullStackJS_Guidelines.md b/specs/99-archives/docs/Markdown/FullStackJS_Guidelines.md index 5782d29..e3b71c8 100644 --- a/specs/99-archives/docs/Markdown/FullStackJS_Guidelines.md +++ b/specs/99-archives/docs/Markdown/FullStackJS_Guidelines.md @@ -10,6 +10,7 @@ Focus on **clarity**, **maintainability**, **consistency**, and **accessibility* ## ⚙️ TypeScript General Guidelines ### Basic Principles + - Use **English** for all code and documentation. - Explicitly type all variables, parameters, and return values. - Avoid `any`; create custom types or interfaces. @@ -18,13 +19,14 @@ Focus on **clarity**, **maintainability**, **consistency**, and **accessibility* - Avoid blank lines within functions. ### Naming Conventions -| Entity | Convention | Example | -|:--|:--|:--| -| Classes | PascalCase | `UserService` | -| Variables & Functions | camelCase | `getUserInfo` | -| Files & Folders | kebab-case | `user-service.ts` | -| Environment Variables | UPPERCASE | `DATABASE_URL` | -| Booleans | Verb + Noun | `isActive`, `canDelete`, `hasPermission` | + +| Entity | Convention | Example | +| :-------------------- | :---------- | :--------------------------------------- | +| Classes | PascalCase | `UserService` | +| Variables & Functions | camelCase | `getUserInfo` | +| Files & Folders | kebab-case | `user-service.ts` | +| Environment Variables | UPPERCASE | `DATABASE_URL` | +| Booleans | Verb + Noun | `isActive`, `canDelete`, `hasPermission` | Use full words — no abbreviations — except for standard ones (`API`, `URL`, `req`, `res`, `err`, `ctx`). @@ -82,6 +84,7 @@ Use full words — no abbreviations — except for standard ones (`API`, `URL`, # 🏗️ Backend (NestJS) ### Principles + - **Modular architecture**: - One module per domain. - Controller → Service → Model structure. @@ -91,12 +94,14 @@ Use full words — no abbreviations — except for standard ones (`API`, `URL`, - Configs, decorators, DTOs, guards, interceptors, notifications, shared services, types, validators. ### Core Functionalities + - Global **filters** for exception handling. - **Middlewares** for request handling. - **Guards** for permissions and RBAC. - **Interceptors** for response transformation and logging. ### Testing + - Use **Jest** for testing. - Test each controller and service. - Add `admin/test` endpoint as a smoke test. @@ -106,10 +111,12 @@ Use full words — no abbreviations — except for standard ones (`API`, `URL`, # 🖥️ Frontend (NextJS / React) ### Developer Profile + Senior-level TypeScript + React/NextJS engineer. Expert in **TailwindCSS**, **Shadcn/UI**, and **Radix** for UI development. ### Code Implementation Guidelines + - Use **early returns** for clarity. - Always style with **TailwindCSS** classes. - Prefer `class:` conditional syntax over ternary operators. @@ -121,6 +128,7 @@ Expert in **TailwindCSS**, **Shadcn/UI**, and **Radix** for UI development. - Always import required modules explicitly. ### UI/UX with React + - Use **semantic HTML**. - Apply **responsive Tailwind** classes. - Maintain visual hierarchy with typography and spacing. @@ -132,37 +140,44 @@ Expert in **TailwindCSS**, **Shadcn/UI**, and **Radix** for UI development. # 🎨 UI/UX (Bootstrap Integration) ### Key Principles + - Use **Bootstrap 5+** for responsive design and consistent UI. - Focus on **maintainability**, **readability**, and **accessibility**. - Use clear and descriptive class names. ### Bootstrap Usage + - Structure layout with **container**, **row**, **col**. - Use built-in **components** (buttons, modals, alerts, etc.) instead of custom CSS. - Apply **utility classes** for quick styling (spacing, colors, text, etc.). - Ensure **ARIA compliance** and semantic markup. ### Form Validation & Errors + - Use Bootstrap’s built-in validation states. - Show errors with **alert components**. - Include labels, placeholders, and feedback messages. ### Dependencies + - Bootstrap (latest CSS + JS) - Optionally jQuery (for legacy interactive components) ### Bootstrap-Specific Guidelines + - Customize Bootstrap via **Sass variables** and **mixins**. - Use responsive visibility utilities. - Avoid overriding Bootstrap; extend it. - Follow official documentation for examples. ### Performance Optimization + - Include only necessary Bootstrap modules. - Use CDN for assets and caching. - Optimize images and assets for mobile. ### Key Conventions + 1. Follow Bootstrap’s naming and structure. 2. Prioritize **responsiveness** and **accessibility**. 3. Keep the file structure organized and modular. @@ -171,15 +186,15 @@ Expert in **TailwindCSS**, **Shadcn/UI**, and **Radix** for UI development. # 🔗 Full Stack Integration Guidelines -| Aspect | Backend (NestJS) | Frontend (NextJS) | UI Layer (Bootstrap/Tailwind) | -|:--|:--|:--|:--| -| API | REST / GraphQL Controllers | API hooks via fetch/axios | Components consuming data | -| Validation | `class-validator` DTOs | `zod` / form-level validation | Bootstrap validation feedback | -| Auth | Guards, JWT | NextAuth / cookies | Auth UI states | -| Errors | Global filters | Toasts / modals | Alerts / feedback | -| Testing | Jest (unit/e2e) | Vitest / Playwright | Visual regression | -| Styles | Scoped modules | Tailwind / Shadcn | Bootstrap utilities | -| Accessibility | Guards + filters | ARIA attributes | Semantic HTML | +| Aspect | Backend (NestJS) | Frontend (NextJS) | UI Layer (Bootstrap/Tailwind) | +| :------------ | :------------------------- | :---------------------------- | :---------------------------- | +| API | REST / GraphQL Controllers | API hooks via fetch/axios | Components consuming data | +| Validation | `class-validator` DTOs | `zod` / form-level validation | Bootstrap validation feedback | +| Auth | Guards, JWT | NextAuth / cookies | Auth UI states | +| Errors | Global filters | Toasts / modals | Alerts / feedback | +| Testing | Jest (unit/e2e) | Vitest / Playwright | Visual regression | +| Styles | Scoped modules | Tailwind / Shadcn | Bootstrap utilities | +| Accessibility | Guards + filters | ARIA attributes | Semantic HTML | --- diff --git a/specs/99-archives/docs/Markdown/FullStackJS_Guidelines01.md b/specs/99-archives/docs/Markdown/FullStackJS_Guidelines01.md index a3f592a..669c100 100644 --- a/specs/99-archives/docs/Markdown/FullStackJS_Guidelines01.md +++ b/specs/99-archives/docs/Markdown/FullStackJS_Guidelines01.md @@ -10,6 +10,7 @@ Focus on **clarity**, **maintainability**, **consistency**, and **accessibility* ## ⚙️ TypeScript General Guidelines ### Basic Principles + - Use **English** for all code and documentation. - Explicitly type all variables, parameters, and return values. - Avoid `any`; create custom types or interfaces. @@ -18,13 +19,14 @@ Focus on **clarity**, **maintainability**, **consistency**, and **accessibility* - Avoid blank lines within functions. ### Naming Conventions -| Entity | Convention | Example | -|:--|:--|:--| -| Classes | PascalCase | `UserService` | -| Variables & Functions | camelCase | `getUserInfo` | -| Files & Folders | kebab-case | `user-service.ts` | -| Environment Variables | UPPERCASE | `DATABASE_URL` | -| Booleans | Verb + Noun | `isActive`, `canDelete`, `hasPermission` | + +| Entity | Convention | Example | +| :-------------------- | :---------- | :--------------------------------------- | +| Classes | PascalCase | `UserService` | +| Variables & Functions | camelCase | `getUserInfo` | +| Files & Folders | kebab-case | `user-service.ts` | +| Environment Variables | UPPERCASE | `DATABASE_URL` | +| Booleans | Verb + Noun | `isActive`, `canDelete`, `hasPermission` | Use full words — no abbreviations — except for standard ones (`API`, `URL`, `req`, `res`, `err`, `ctx`). @@ -82,6 +84,7 @@ Use full words — no abbreviations — except for standard ones (`API`, `URL`, # 🏗️ Backend (NestJS) ### Principles + - **Modular architecture**: - One module per domain. - Controller → Service → Model structure. @@ -91,12 +94,14 @@ Use full words — no abbreviations — except for standard ones (`API`, `URL`, - Configs, decorators, DTOs, guards, interceptors, notifications, shared services, types, validators. ### Core Functionalities + - Global **filters** for exception handling. - **Middlewares** for request handling. - **Guards** for permissions and RBAC. - **Interceptors** for response transformation and logging. ### Testing + - Use **Jest** for testing. - Test each controller and service. - Add `admin/test` endpoint as a smoke test. @@ -106,10 +111,12 @@ Use full words — no abbreviations — except for standard ones (`API`, `URL`, # 🖥️ Frontend (NextJS / React) ### Developer Profile + Senior-level TypeScript + React/NextJS engineer. Expert in **TailwindCSS**, **Shadcn/UI**, and **Radix** for UI development. ### Code Implementation Guidelines + - Use **early returns** for clarity. - Always style with **TailwindCSS** classes. - Prefer `class:` conditional syntax over ternary operators. @@ -121,6 +128,7 @@ Expert in **TailwindCSS**, **Shadcn/UI**, and **Radix** for UI development. - Always import required modules explicitly. ### UI/UX with React + - Use **semantic HTML**. - Apply **responsive Tailwind** classes. - Maintain visual hierarchy with typography and spacing. @@ -132,37 +140,44 @@ Expert in **TailwindCSS**, **Shadcn/UI**, and **Radix** for UI development. # 🎨 UI/UX (Bootstrap Integration) ### Key Principles + - Use **Bootstrap 5+** for responsive design and consistent UI. - Focus on **maintainability**, **readability**, and **accessibility**. - Use clear and descriptive class names. ### Bootstrap Usage + - Structure layout with **container**, **row**, **col**. - Use built-in **components** (buttons, modals, alerts, etc.) instead of custom CSS. - Apply **utility classes** for quick styling (spacing, colors, text, etc.). - Ensure **ARIA compliance** and semantic markup. ### Form Validation & Errors + - Use Bootstrap’s built-in validation states. - Show errors with **alert components**. - Include labels, placeholders, and feedback messages. ### Dependencies + - Bootstrap (latest CSS + JS) - Optionally jQuery (for legacy interactive components) ### Bootstrap-Specific Guidelines + - Customize Bootstrap via **Sass variables** and **mixins**. - Use responsive visibility utilities. - Avoid overriding Bootstrap; extend it. - Follow official documentation for examples. ### Performance Optimization + - Include only necessary Bootstrap modules. - Use CDN for assets and caching. - Optimize images and assets for mobile. ### Key Conventions + 1. Follow Bootstrap’s naming and structure. 2. Prioritize **responsiveness** and **accessibility**. 3. Keep the file structure organized and modular. @@ -171,15 +186,15 @@ Expert in **TailwindCSS**, **Shadcn/UI**, and **Radix** for UI development. # 🔗 Full Stack Integration Guidelines -| Aspect | Backend (NestJS) | Frontend (NextJS) | UI Layer (Bootstrap/Tailwind) | -|:--|:--|:--|:--| -| API | REST / GraphQL Controllers | API hooks via fetch/axios | Components consuming data | -| Validation | `class-validator` DTOs | `zod` / form-level validation | Bootstrap validation feedback | -| Auth | Guards, JWT | NextAuth / cookies | Auth UI states | -| Errors | Global filters | Toasts / modals | Alerts / feedback | -| Testing | Jest (unit/e2e) | Vitest / Playwright | Visual regression | -| Styles | Scoped modules | Tailwind / Shadcn | Bootstrap utilities | -| Accessibility | Guards + filters | ARIA attributes | Semantic HTML | +| Aspect | Backend (NestJS) | Frontend (NextJS) | UI Layer (Bootstrap/Tailwind) | +| :------------ | :------------------------- | :---------------------------- | :---------------------------- | +| API | REST / GraphQL Controllers | API hooks via fetch/axios | Components consuming data | +| Validation | `class-validator` DTOs | `zod` / form-level validation | Bootstrap validation feedback | +| Auth | Guards, JWT | NextAuth / cookies | Auth UI states | +| Errors | Global filters | Toasts / modals | Alerts / feedback | +| Testing | Jest (unit/e2e) | Vitest / Playwright | Visual regression | +| Styles | Scoped modules | Tailwind / Shadcn | Bootstrap utilities | +| Accessibility | Guards + filters | ARIA attributes | Semantic HTML | --- @@ -191,7 +206,6 @@ Expert in **TailwindCSS**, **Shadcn/UI**, and **Radix** for UI development. - Use **Prettier + ESLint** for consistent formatting. - Prefer **clarity over cleverness** — readable code wins. - --- # 🗂️ DMS-Specific Conventions (Document Management System) @@ -215,12 +229,13 @@ src/ ``` ### Naming Convention -| Entity | Example | -|:--|:--| -| Table | `rfa_revisions`, `drawing_contracts` | -| DTO | `CreateRfaDto`, `UpdateContractDto` | -| Controller | `rfas.controller.ts` | -| Service | `rfas.service.ts` | + +| Entity | Example | +| :--------- | :----------------------------------- | +| Table | `rfa_revisions`, `drawing_contracts` | +| DTO | `CreateRfaDto`, `UpdateContractDto` | +| Controller | `rfas.controller.ts` | +| Service | `rfas.service.ts` | --- @@ -237,11 +252,13 @@ updateRFA(@Param('id') id: string) { ``` ### Roles + - **Admin**: Full access to all modules. - **Editor**: Modify data within assigned modules. - **Viewer**: Read‑only access. ### Permissions + - `rfa.create`, `rfa.update`, `rfa.delete`, `rfa.view` - `drawing.upload`, `drawing.map`, `drawing.view` - `contract.assign`, `contract.view` @@ -254,14 +271,14 @@ Seed mapping between roles and permissions via seeder scripts. Log all CRUD and mapping operations: -| Field | Description | -|:--|:--| -| `actor_id` | user performing the action | -| `module_name` | e.g. `rfa`, `drawing` | -| `action` | `create`, `update`, `delete`, `map` | -| `target_id` | primary id of the record | -| `timestamp` | UTC timestamp | -| `description` | contextual note | +| Field | Description | +| :------------ | :---------------------------------- | +| `actor_id` | user performing the action | +| `module_name` | e.g. `rfa`, `drawing` | +| `action` | `create`, `update`, `delete`, `map` | +| `target_id` | primary id of the record | +| `timestamp` | UTC timestamp | +| `description` | contextual note | Example implementation: @@ -280,6 +297,7 @@ await this.auditLogService.log({ ## 📂 File Handling ### File Upload Standard + - Upload path: `/storage/{year}/{month}/` - File naming: `{drawing_code}_{revision}_{timestamp}.pdf` - Allowed types: `pdf, dwg, docx, xlsx, zip` @@ -288,19 +306,21 @@ await this.auditLogService.log({ - Serve via secure endpoint `/files/:id/download`. ### Access Control + Each file download must verify user permission (`hasPermission('drawing.view')`). --- ## 📊 Reporting & Exports -| Report | Description | -|:--|:--| -| **Report A** | RFA → Drawings → All Drawing Revisions | -| **Report B** | RFA Revision Timeline vs Drawing Revision | +| Report | Description | +| :---------------- | :---------------------------------------------- | +| **Report A** | RFA → Drawings → All Drawing Revisions | +| **Report B** | RFA Revision Timeline vs Drawing Revision | | **Dashboard KPI** | RFAs, Drawings, Revisions, Transmittals summary | ### Export Rules + - Export formats: CSV, Excel, PDF. - Provide print view. - Include source entity link (e.g., `/rfas/:id`). @@ -310,11 +330,13 @@ Each file download must verify user permission (`hasPermission('drawing.view')`) ## 🧮 Frontend: DataTable & Form Patterns ### DataTable (Server‑Side) + - Endpoint: `/api/{module}?page=1&pageSize=20` - Must support: pagination, sorting, search, filters. - Always display latest revision inline (for RFA/Drawing). ### Form Standards + - Dependent dropdowns: - Contract → Subcategory - RFA → Related Drawing @@ -326,30 +348,30 @@ Each file download must verify user permission (`hasPermission('drawing.view')`) ## 🧭 Dashboard & Activity Feed ### Dashboard Cards + - Show latest RFA, Drawing, Transmittal, KPI summary. - Include quick links to modules. ### Activity Feed + - Display recent AuditLog actions (10 latest). ```ts // Example response -[ - { user: 'admin', action: 'Updated RFA 023-Rev02', time: '2025‑11‑04T09:30Z' } -] +[{ user: 'admin', action: 'Updated RFA 023-Rev02', time: '2025‑11‑04T09:30Z' }]; ``` --- ## ✅ Integration Summary -| Aspect | Backend | Frontend | Description | -|:--|:--|:--| -| **File Handling** | Secure storage, token check | Upload/Preview UI | Consistent standard path | -| **RBAC** | `RequirePermission` guard | Hide/disable UI actions | Unified permission logic | -| **AuditLog** | Persist actions | Show in dashboard | Traceable user activity | -| **Reports** | Aggregation queries | Export + Print | Consistent data pipeline | -| **DataTables** | Server‑side paging | Filter/Search UI | Scalable dataset management | +| Aspect | Backend | Frontend | Description | +| :---------------- | :-------------------------- | :---------------------- | --------------------------- | +| **File Handling** | Secure storage, token check | Upload/Preview UI | Consistent standard path | +| **RBAC** | `RequirePermission` guard | Hide/disable UI actions | Unified permission logic | +| **AuditLog** | Persist actions | Show in dashboard | Traceable user activity | +| **Reports** | Aggregation queries | Export + Print | Consistent data pipeline | +| **DataTables** | Server‑side paging | Filter/Search UI | Scalable dataset management | --- diff --git a/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_0_application _requirements.md b/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_0_application _requirements.md index db0e7a1..4256e79 100644 --- a/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_0_application _requirements.md +++ b/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_0_application _requirements.md @@ -1,82 +1,84 @@ # 📝 Documents Management Sytem Version 1.1.0: Application Requirements Specification + ## 📌 1. วัตถุประสงค์ -สร้างเว็บแอปพลิเคชั่นสำหรับระบบบริหารจัดการเอกสารโครงการ (Document Management System) ที่สามารถจัดการและควบคุม การสื่อสารด้วยเอกสารที่ซับซ้อน อย่างมีประสิทธิภาพ -* มีฟังก์ชันหลักในการอัปโหลด จัดเก็บ ค้นหา แชร์ และควบคุมสิทธิ์การเข้าถึงเอกสาร -* ช่วยลดการใช้เอกสารกระดาษ เพิ่มความปลอดภัยในการจัดเก็บข้อมูล -* เพิ่มความสะดวกในการทำงานร่วมกันระหว่างองกรณ์ -## 🛠️ 2. สถาปัตยกรรมและเทคโนโลยี (System Architecture & Technology Stack) +สร้างเว็บแอปพลิเคชั่นสำหรับระบบบริหารจัดการเอกสารโครงการ (Document Management System) ที่สามารถจัดการและควบคุม การสื่อสารด้วยเอกสารที่ซับซ้อน อย่างมีประสิทธิภาพ -ใช้สถาปัตยกรรมแบบ Headless/API-First ที่ทันสมัย ทำงานทั้งหมดบน QNAP Server ผ่าน Container Station เพื่อความสะดวกในการจัดการและบำรุงรักษา, Domain: np-dms.work, มี fix ip, รัน docker command ใน application ของ Container Station ได้โดยตรง, ประกอบด้วย +- มีฟังก์ชันหลักในการอัปโหลด จัดเก็บ ค้นหา แชร์ และควบคุมสิทธิ์การเข้าถึงเอกสาร +- ช่วยลดการใช้เอกสารกระดาษ เพิ่มความปลอดภัยในการจัดเก็บข้อมูล +- เพิ่มความสะดวกในการทำงานร่วมกันระหว่างองกรณ์ -* 2.1. Infrastructure & Environment: - - Server: QNAP (Model: TS-473A, RAM: 32GB, CPU: AMD Ryzen V1500B) - - Containerization: Container Station (Docker & Docker Compose) ใช้ UI ของ Container Station เป็นหลัก ในการ configuration และการรัน docker command - - Development Environment: VS Code on Windows 11 - - Domain: np-dms.work, www.np-dms.work - - ip: 159.192.126.103 - - Docker Network: ทุก Service จะเชื่อมต่อผ่านเครือข่ายกลางชื่อ lcbp3 เพื่อให้สามารถสื่อสารกันได้ - - Data Storage: /share/dms-data บน QNAP - - ข้อจำกัด: ไม่สามารถใช้ .env ในการกำหนดตัวแปรภายนอกได้ ต้องกำหนดใน docker-compose.yml เท่านั้น -* 2.2. Code Hosting: - - Application name: git - - Service: Gitea (Self-hosted on QNAP) - - Service name: gitea - - Domain: git.np-dms.work - - หน้าที่: เป็นศูนย์กลางในการเก็บและจัดการเวอร์ชันของโค้ด (Source Code) สำหรับทุกส่วน -* 2.3. Backend / Data Platform: - - Application name: lcbp3-backend - - Service: NestJS - - Service name: backend - - Domain: backend.np-dms.work - - Framework: NestJS (Node.js, TypeScript, ESM) - - หน้าที่: จัดการโครงสร้างข้อมูล (Data Models), สร้าง API, จัดการสิทธิ์ผู้ใช้ (Roles & Permissions), และสร้าง Workflow ทั้งหมดของระบบ -* 2.4. Database: - - Application name: lcbp3-db - - Service: mariadb:10.11 - - Service name: mariadb - - Domain: db.np-dms.work - - หน้าที่: ฐานข้อมูลหลักสำหรับเก็บข้อมูลทั้งหมด - - Tooling: DBeaver (Community Edition), phpmyadmin สำหรับการออกแบบและจัดการฐานข้อมูล -* 2.5. Database management: - - Application name: lcbp3-db - - Service: phpmyadmin:5-apache - - Service name: pma - - Domain: pma.np-dms.work - - หน้าที่: จัดการฐานข้อมูล mariadb ผ่าน Web UI -* 2.6. Frontend: - - Application name: lcbp3-frontend - - Service: next.js - - Service name: frontend - - Domain: lcbp3.np-dms.work - - Framework: Next.js (App Router, React, TypeScript, ESM) - - Styling: Tailwind CSS + PostCSS - - Component Library: shadcn/ui - - หน้าที่: สร้างหน้าตาเว็บแอปพลิเคชันสำหรับให้ผู้ใช้งานเข้ามาดู Dashboard, จัดการเอกสาร, และติดตามงาน โดยจะสื่อสารกับ Backend ผ่าน API -* 2.7. Workflow automation: - - Application name: lcbp3-n8n - - Service: n8nio/n8n:latest - - Service name: n8n - - Domain: n8n.np-dms.work - - หน้าที่: จัดการ workflow ระหว่าง Backend และ Line -* 2.8. Reverse Proxy: - - Application name: lcbp3-npm - - Service: Nginx Proxy Manager (nginx-proxy-manage: latest) - - Service name: npm - - Domain: npm.np-dms.work - - หน้าที่: เป็นด่านหน้าในการรับ-ส่งข้อมูล จัดการโดเมนทั้งหมด, ทำหน้าที่เป็น Proxy ชี้ไปยัง Service ที่ถูกต้อง, และจัดการ SSL Certificate (HTTPS) ให้อัตโนมัติ +## 🛠️ 2. สถาปัตยกรรมและเทคโนโลยี (System Architecture & Technology Stack) +ใช้สถาปัตยกรรมแบบ Headless/API-First ที่ทันสมัย ทำงานทั้งหมดบน QNAP Server ผ่าน Container Station เพื่อความสะดวกในการจัดการและบำรุงรักษา, Domain: np-dms.work, มี fix ip, รัน docker command ใน application ของ Container Station ได้โดยตรง, ประกอบด้วย + +- 2.1. Infrastructure & Environment: + - Server: QNAP (Model: TS-473A, RAM: 32GB, CPU: AMD Ryzen V1500B) + - Containerization: Container Station (Docker & Docker Compose) ใช้ UI ของ Container Station เป็นหลัก ในการ configuration และการรัน docker command + - Development Environment: VS Code on Windows 11 + - Domain: np-dms.work, www.np-dms.work + - ip: 159.192.126.103 + - Docker Network: ทุก Service จะเชื่อมต่อผ่านเครือข่ายกลางชื่อ lcbp3 เพื่อให้สามารถสื่อสารกันได้ + - Data Storage: /share/dms-data บน QNAP + - ข้อจำกัด: ไม่สามารถใช้ .env ในการกำหนดตัวแปรภายนอกได้ ต้องกำหนดใน docker-compose.yml เท่านั้น +- 2.2. Code Hosting: + - Application name: git + - Service: Gitea (Self-hosted on QNAP) + - Service name: gitea + - Domain: git.np-dms.work + - หน้าที่: เป็นศูนย์กลางในการเก็บและจัดการเวอร์ชันของโค้ด (Source Code) สำหรับทุกส่วน +- 2.3. Backend / Data Platform: + - Application name: lcbp3-backend + - Service: NestJS + - Service name: backend + - Domain: backend.np-dms.work + - Framework: NestJS (Node.js, TypeScript, ESM) + - หน้าที่: จัดการโครงสร้างข้อมูล (Data Models), สร้าง API, จัดการสิทธิ์ผู้ใช้ (Roles & Permissions), และสร้าง Workflow ทั้งหมดของระบบ +- 2.4. Database: + - Application name: lcbp3-db + - Service: mariadb:10.11 + - Service name: mariadb + - Domain: db.np-dms.work + - หน้าที่: ฐานข้อมูลหลักสำหรับเก็บข้อมูลทั้งหมด + - Tooling: DBeaver (Community Edition), phpmyadmin สำหรับการออกแบบและจัดการฐานข้อมูล +- 2.5. Database management: + - Application name: lcbp3-db + - Service: phpmyadmin:5-apache + - Service name: pma + - Domain: pma.np-dms.work + - หน้าที่: จัดการฐานข้อมูล mariadb ผ่าน Web UI +- 2.6. Frontend: + - Application name: lcbp3-frontend + - Service: next.js + - Service name: frontend + - Domain: lcbp3.np-dms.work + - Framework: Next.js (App Router, React, TypeScript, ESM) + - Styling: Tailwind CSS + PostCSS + - Component Library: shadcn/ui + - หน้าที่: สร้างหน้าตาเว็บแอปพลิเคชันสำหรับให้ผู้ใช้งานเข้ามาดู Dashboard, จัดการเอกสาร, และติดตามงาน โดยจะสื่อสารกับ Backend ผ่าน API +- 2.7. Workflow automation: + - Application name: lcbp3-n8n + - Service: n8nio/n8n:latest + - Service name: n8n + - Domain: n8n.np-dms.work + - หน้าที่: จัดการ workflow ระหว่าง Backend และ Line +- 2.8. Reverse Proxy: + - Application name: lcbp3-npm + - Service: Nginx Proxy Manager (nginx-proxy-manage: latest) + - Service name: npm + - Domain: npm.np-dms.work + - หน้าที่: เป็นด่านหน้าในการรับ-ส่งข้อมูล จัดการโดเมนทั้งหมด, ทำหน้าที่เป็น Proxy ชี้ไปยัง Service ที่ถูกต้อง, และจัดการ SSL Certificate (HTTPS) ให้อัตโนมัติ ## 📦 3. ข้อกำหนดด้านฟังก์ชันการทำงาน (Functional Requirements) -* 3.1. การจัดการโครงสร้างโครงการและองค์กร +- 3.1. การจัดการโครงสร้างโครงการและองค์กร - 3.1.1. โครงการ (Projects): ระบบต้องสามารถจัดการเอกสารภายในหลายโครงการได้ (ปัจจุบันมี 4 โครงการ และจะเพิ่มขึ้นในอนาคต) - 3.1.2. สัญญา (Contracts): ระบบต้องสามารถจัดการเอกสารภายในแต่ละสัญญาได้ ในแต่ละโครงการ มีได้หลายสัญญา หรืออย่างน้อย 1 สัญญา - 3.1.3. องค์กร (Organizations): - มีหลายองค์กรในโครงการ องค์กรณ์ที่เป็น Owner, Designer และ Consultant สามารถอยู่ในหลายโครงการและหลายสัญญาได้ - Contractor จะถือ 1 สัญญา และอยู่ใน 1 โครงการเท่านั้น -* 3.2. การจัดการเอกสารโต้ตอบ (Correspondence Management) +- 3.2. การจัดการเอกสารโต้ตอบ (Correspondence Management) - 3.2.1. วัตถุประสงค์: เอกสารโต้ตอบ (correspondences) ระหว่างองกรณื-องกรณ์ ภายใน โครงการ (Projects) และระหว่าง องค์กร-องค์กร ภายนอก โครงการ (Projects) - 3.2.2. ประเภทเอกสาร: ระบบต้องรองรับเอกสารรูปแบบ ไฟล์ PDF หลายประเภท (Types) เช่น จดหมาย (Letter), อีเมล์ (Email), Request for Information (RFI), และสามารถเพิ่มประเภทใหม่ได้ในภายหลัง - 3.2.3. การสร้างเอกสาร (Correspondence): @@ -86,22 +88,21 @@ - เอกสารสามารถอ้างถึง (Reference) เอกสารฉบับก่อนหน้าได้หลายฉบับ - สามารถกำหนด Tag ได้หลาย Tag เพื่อจัดกลุ่มและใช้ในการค้นหาขั้นสูง - 3.2.5. การจัดการ: มีการจัดการอย่างน้อยดังนี้ -0 - สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบของ องกรณ์ ที่อเป็นผู้รับ ได้ - - มีระบบแจ้งเตือน ให้ผู้รับผิดชอบขององกรณ์ที่เป็น ผู้รับ/ผู้ส่ง ทราบ เมื่อมีเอกสารใหม่ หรือมีการเปลี่ยนสถานะ + 0 - สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบของ องกรณ์ ที่อเป็นผู้รับ ได้ - มีระบบแจ้งเตือน ให้ผู้รับผิดชอบขององกรณ์ที่เป็น ผู้รับ/ผู้ส่ง ทราบ เมื่อมีเอกสารใหม่ หรือมีการเปลี่ยนสถานะ -* 3.3. การจัดกาแบบคู่สัญญา (Contract Drawing) +- 3.3. การจัดกาแบบคู่สัญญา (Contract Drawing) - 3.3.1. วัตถุประสงค์: แบบคู่สัญญา (Contract Drawing) ใช้เพื่ออ้างอิงและใช้ในการตรวจสอบ - 3.3.2. ประเภทเอกสาร: ไฟล์ PDF - 3.3.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ - - 3.3.4. การอ้างอิงและจัดกลุ่ม: ใช้สำหรับอ้างอิง ใน Shop Drawings, มีการจัดหมวดหมู่ของ Contract Drawing + - 3.3.4. การอ้างอิงและจัดกลุ่ม: ใช้สำหรับอ้างอิง ใน Shop Drawings, มีการจัดหมวดหมู่ของ Contract Drawing -* 3.4. การจัดกาแบบก่อสร้าง (Shop Drawing) +- 3.4. การจัดกาแบบก่อสร้าง (Shop Drawing) - 3.4.1. วัตถุประสงค์: แบบก่อสร้าง (Shop Drawing) ใช้เในการตรวจสอบ โดยจัดส่งด้วย Request for Approval (RFA) - 3.4.2. ประเภทเอกสาร: ไฟล์ PDF - 3.4.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ - - 3.4.4. การอ้างอิงและจัดกลุ่ม: ช้สำหรับอ้างอิง ใน Shop Drawings, มีการจัดหมวดหมู่ของ Shop Drawings + - 3.4.4. การอ้างอิงและจัดกลุ่ม: ช้สำหรับอ้างอิง ใน Shop Drawings, มีการจัดหมวดหมู่ของ Shop Drawings -* 3.5. การจัดการเอกสารขออนุมัติ (Request for Approval & Workflow) +- 3.5. การจัดการเอกสารขออนุมัติ (Request for Approval & Workflow) - 3.5.1. วัตถุประสงค์: เอกสารขออนุมัติ (Request for Approval) ใช้ในการส่งเอกสารเพิอขออนุมัติ - 3.5.2. ประเภทเอกสาร: Request for Approval (RFA) เป็นชนิดหนึ่งของ Correspondence ที่มีลักษณะเฉพาะที่ต้องได้รับการอนุมัติ มีประเภทดังนี้: - Request for Drawing Approval (RFA_DWG) @@ -113,19 +114,18 @@ - เอกสาร RFA_DWG จะประกอบไปด้วย Shop Drawing (shop_drawings) หลายแผ่น ซึ่งแต่ละแผ่นมี Revision ของตัวเอง - Shop Drawing แต่ละ Revision สามารถอ้างอิงถึง Contract Drawing (Ccontract_drawings) หลายแผ่น หรือไม่อ้างถึงก็ได้ - ระบบต้องมีส่วนสำหรับจัดการข้อมูล Master Data ของทั้ง Shop Drawing และ Contract Drawing แยกจากกัน - - 3.6.5. Workflow การอนุมัติ: ต้องรองรับกระบวนการอนุมัติที่ซับซ้อนและเป็นลำดับ เช่น - - ส่งจาก Originator -> Organization 1 -> Organization 2 -> Organization 3 แล้วส่งผลกลับตามลำดับเดิม (โดยถ้า องกรณ์ใดใน Workflow ให้ส่งกลับ ก็สามารถส่งผลกลับตามลำดับเดิมโดยไม่ต้องรอให้ถึง องกรณืในลำดับถัดไป) + - 3.6.5. Workflow การอนุมัติ: ต้องรองรับกระบวนการอนุมัติที่ซับซ้อนและเป็นลำดับ เช่น + - ส่งจาก Originator -> Organization 1 -> Organization 2 -> Organization 3 แล้วส่งผลกลับตามลำดับเดิม (โดยถ้า องกรณ์ใดใน Workflow ให้ส่งกลับ ก็สามารถส่งผลกลับตามลำดับเดิมโดยไม่ต้องรอให้ถึง องกรณืในลำดับถัดไป) - 3.6.6. การจัดการ: มีการจัดการอย่างน้อยดังนี้ -0 - สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบของ องกรณ์ ที่อยู่ใน Workflow ได้ - - มีระบบแจ้งเตือน ให้ผู้รับผิดชอบของ องกรณ์ ที่อยู่ใน Workflow ทราบ เมื่อมี RFA ใหม่ หรือมีการเปลี่ยนสถานะ + 0 - สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบของ องกรณ์ ที่อยู่ใน Workflow ได้ - มีระบบแจ้งเตือน ให้ผู้รับผิดชอบของ องกรณ์ ที่อยู่ใน Workflow ทราบ เมื่อมี RFA ใหม่ หรือมีการเปลี่ยนสถานะ -* 3.6.การจัดการเอกสารนำส่ง (Transmittals) +- 3.6.การจัดการเอกสารนำส่ง (Transmittals) - 3.6.1. วัตถุประสงค์: เอกสารนำส่ง ใช้สำหรับ นำส่ง Request for Approval (RFAS) หลายฉบับ ไปยังองค์กรอื่น - 3.6.2. ประเภทเอกสาร: ไฟล์ PDF - 3.6.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ - 3.6.4. การอ้างอิงและจัดกลุ่ม: เอกสารนำส่ง เป็นส่วนหนึ่งใน Correspondence -* 3.7. ใบเวียนเอกสารภายใน (Internal Circulation Sheet) +- 3.7. ใบเวียนเอกสารภายใน (Internal Circulation Sheet) - 3.7.1. วัตถุประสงค์: การสื่อสาร เอกสาร (Correspondence) ทุกฉบับ จะมีใบเวียนเอกสารเพื่อควบคุมและมอบหมายงานภายในองค์กร (สามารถดูและแก้ไขได้เฉพาะคนในองค์กร) - 3.7.2. ประเภทเอกสาร: ไฟล์ PDF - 3.7.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ในองค์กรนั้น สามารถสร้างและแก้ไขได้ @@ -138,60 +138,59 @@ - มีระบบแจ้งเตือนเมื่อมี Circulation ใหม่ และแจ้งเตือนล่วงหน้าก่อนถึงวันแล้วเสร็จ - สามารถปิด Circulation ได้เมื่อดำเนินการตอบกลับไปยังองค์กรผู้ส่ง (Originator) แล้ว หรือ รับทราบแล้ว (For Information) -* 3.8. ประวัติการแก้ไข (Revisions): ระบบจะเก็บประวัติการสร้างและแก้ไข เอกสารทั้งหมด +- 3.8. ประวัติการแก้ไข (Revisions): ระบบจะเก็บประวัติการสร้างและแก้ไข เอกสารทั้งหมด -* 3.9. การจัดเก็บ: เอกสารและไฟล์แนบจะถูกจัดเก็บในโฟลเดอร์บน Server (/share/dms-data/) โดยมีการอ้างอิงข้อมูล (Metadata) ในฐานข้อมูล และสามารถจัดเรียงตามวันที่ในเอกสาร (Document Date) ได้ ตัวอย่างเช่น +- 3.9. การจัดเก็บ: เอกสารและไฟล์แนบจะถูกจัดเก็บในโฟลเดอร์บน Server (/share/dms-data/) โดยมีการอ้างอิงข้อมูล (Metadata) ในฐานข้อมูล และสามารถจัดเรียงตามวันที่ในเอกสาร (Document Date) ได้ ตัวอย่างเช่น - Correspondence จัดเก็บใน /share/dms-data/correspondences/YYMMDD/ชื่อไฟล์.pdf - Request for Approval จัดเก็บใน /share/dms-data/rfas/YYMMDD/ชื่อไฟล์.pdf - Shop Drawings จัดเก็บใน /share/dms-data/shop_drawings/YYMMDD/ชื่อไฟล์.pdf ## 🔐 4. ข้อกำหนดด้านสิทธิ์และการเข้าถึง (Access Control Requirements) -* 4.1. ภาพรวม: ผู้ใช้และองค์กรสามารถดูและแก้ไขเอกสารได้ตามสิทธิ์ที่ได้รับ โดยระบบสิทธิ์จะเป็นแบบ Role-Based Access Control (RBAC) -* 4.2. ระดับของสิทธิ์: +- 4.1. ภาพรวม: ผู้ใช้และองค์กรสามารถดูและแก้ไขเอกสารได้ตามสิทธิ์ที่ได้รับ โดยระบบสิทธิ์จะเป็นแบบ Role-Based Access Control (RBAC) +- 4.2. ระดับของสิทธิ์: - Global Roles: สิทธิ์ในภาพรวมของระบบ - Project-Specific Roles: สิทธิ์ที่ถูกกำหนดให้ผู้ใช้สำหรับโครงการนั้นๆ โดยเฉพาะ (เช่น เป็น Editor ในโครงการ A แต่เป็น Viewer ในโครงการ B) - Contract-Specific Roles: สิทธิ์ที่ถูกกำหนดให้โครงการสำหรับสัญญานั้นๆ (เช่น เป็น Admin ในสัญญา 1 จะเป็น Admin ใน โครงการ A และ ฺB ที่อยู่ในสัญญา 1) -* 4.3. บทบาท (Roles) พื้นฐาน: +- 4.3. บทบาท (Roles) พื้นฐาน: - Superadmin: ไม่มีข้อจำกัดใดๆ สามารถจัดการได้ทุกอย่างข้ามองค์กร - Admin: มีสิทธิ์เต็มที่ แต่จำกัดเฉพาะในองค์กรที่ตัวเองสังกัด สามารถจัดการผู้ใช้ในองค์กรได้ สามารถสร้าง Role ใหม่และกำหนดสิทธิ์ (Permissions) เพิ่มเติมได้ในภายหลังผ่านหน้า Admin - Document Control สามารถ เพิ่ม/แก้ไข/ลบ เอกสาร เฉพาะในองค์กรที่ตัวเองสังกัด ไม่สามารถจัดการผู้ใช้ได้ - Editor: สามารถ เพิ่ม/แก้ไข เอกสารที่กำหนดไว้ เฉพาะในองค์กรที่ตัวเองสังกัด - Viewer: สามารถดู เอกสาร เฉพาะในองค์กรที่ตัวเองสังกัด -* 4.4. การบังคับใช้สิทธิ์: สิทธิ์ขององค์กรจะครอบคลุมสิทธิ์ของผู้ใช้ และการเข้าถึงข้อมูลที่เกี่ยวข้องกับโครงการ (เช่น การแก้ไขเอกสาร) จะถูกตรวจสอบเทียบกับสิทธิ์ที่ผู้ใช้มีในโครงการนั้นๆ โดยเฉพาะ +- 4.4. การบังคับใช้สิทธิ์: สิทธิ์ขององค์กรจะครอบคลุมสิทธิ์ของผู้ใช้ และการเข้าถึงข้อมูลที่เกี่ยวข้องกับโครงการ (เช่น การแก้ไขเอกสาร) จะถูกตรวจสอบเทียบกับสิทธิ์ที่ผู้ใช้มีในโครงการนั้นๆ โดยเฉพาะ ## 👥 5. ข้อกำหนดด้านผู้ใช้งาน (User Interface & Experience) -* 5.1. Layout หลัก: หน้าเว็บใช้รูปแบบ App Shell ที่ประกอบด้วย: +- 5.1. Layout หลัก: หน้าเว็บใช้รูปแบบ App Shell ที่ประกอบด้วย: - Navbar (ส่วนบน): แสดงชื่อระบบ, เมนูผู้ใช้ (Profile), เมนูสำหรับ Admin/Superadmin (จัดการผู้ใช้, จัดการสิทธิ์), และปุ่ม Login/Logout - Sidebar (ด้านข้าง): เป็นเมนูหลักสำหรับเข้าถึงส่วนที่เกี่ยวกับเอกสารทั้งหมด เช่น Dashboard, Correspondences, RFA, Drawings - Main Content Area: พื้นที่สำหรับแสดงเนื้อหาหลักของหน้าที่เลือก -* 5.2. หน้า Landing Page: เป็นหน้าแรกที่แสดงข้อมูลบางส่วนของโครงการสำหรับผู้ใช้ที่ยังไม่ได้ล็อกอิน -* 5.3. หน้า Dashboard: เป็นหน้าแรกหลังจากล็อกอิน ประกอบด้วย: +- 5.2. หน้า Landing Page: เป็นหน้าแรกที่แสดงข้อมูลบางส่วนของโครงการสำหรับผู้ใช้ที่ยังไม่ได้ล็อกอิน +- 5.3. หน้า Dashboard: เป็นหน้าแรกหลังจากล็อกอิน ประกอบด้วย: - การ์ดสรุปภาพรวม (KPI Cards): แสดงข้อมูลสรุปที่สำคัญขององค์กร เช่น จำนวนเอกสาร, งานที่เกินกำหนด - ตาราง "งานของฉัน" (My Tasks Table): แสดงรายการงานทั้งหมดจาก Circulation ที่ผู้ใช้ต้องดำเนินการ -* 5.4. การติดตามสถานะ: องค์กรสามารถติดตามสถานะเอกสารทั้งของตนเอง (Originator) และสถานะเอกสารที่ส่งมาถึงตนเอง (Recipient) -* 5.5. การจัดการข้อมูลส่วนตัว (Profile Page): ผู้ใช้สามารถจัดการข้อมูลส่วนตัวและเปลี่ยนรหัสผ่านของตนเองได้ -* 5.6. การจัดการเอกสารทางเทคนิค (Technical Documents & Workflow): ผู้ใช้สามารถดู Technical Document ในรูปแบบ Workflow ทั้งหมดได้ในหน้าเดียว, ขั้นตอนที่ยังไม่ถึงหรือผ่านไปแล้วจะเป็นรูปแบบ diable, สามารถดำเนินการได้เฉพาะในขั้นตอนที่ได้รับมอบหมายงาน (active) เช่น ตรวจสอบแล้ว เพื่อไปยังขั้นตอนต่อไป, สิทธิ์ admin ขึ้นไป สามรถกด ไปยังขั้นตอนต่อไป ได้ทุกขั้นตอน, การย้อนกลับ ไปขั้นตอนก่อนหน้า สามารถทำได้โดย สิทธิ์ admin ขึ้นไป +- 5.4. การติดตามสถานะ: องค์กรสามารถติดตามสถานะเอกสารทั้งของตนเอง (Originator) และสถานะเอกสารที่ส่งมาถึงตนเอง (Recipient) +- 5.5. การจัดการข้อมูลส่วนตัว (Profile Page): ผู้ใช้สามารถจัดการข้อมูลส่วนตัวและเปลี่ยนรหัสผ่านของตนเองได้ +- 5.6. การจัดการเอกสารทางเทคนิค (Technical Documents & Workflow): ผู้ใช้สามารถดู Technical Document ในรูปแบบ Workflow ทั้งหมดได้ในหน้าเดียว, ขั้นตอนที่ยังไม่ถึงหรือผ่านไปแล้วจะเป็นรูปแบบ diable, สามารถดำเนินการได้เฉพาะในขั้นตอนที่ได้รับมอบหมายงาน (active) เช่น ตรวจสอบแล้ว เพื่อไปยังขั้นตอนต่อไป, สิทธิ์ admin ขึ้นไป สามรถกด ไปยังขั้นตอนต่อไป ได้ทุกขั้นตอน, การย้อนกลับ ไปขั้นตอนก่อนหน้า สามารถทำได้โดย สิทธิ์ admin ขึ้นไป ## 6. ข้อกำหนดที่ไม่ใช่ฟังก์ชันการทำงาน (Non-Functional Requirements) -* 6.1. การบันทึกการกระทำ (Audit Log): ทุกการกระทำที่สำคัญของผู้ใช้ (สร้าง, แก้ไข, ลบ, ส่ง) จะถูกบันทึกไว้ใน audit_logs เพื่อการตรวจสอบย้อนหลัง -* 6.2. การค้นหา (Search): ระบบต้องมีฟังก์ชันการค้นหาขั้นสูง ที่สามารถค้นหาเอกสารจากหลายเงื่อนไขพร้อมกันได้ เช่น ค้นหาจากชื่อเรื่อง, ประเภท, วันที่, และ Tag -* 6.3. การทำรายงาน (Reporting): สามารถจัดทำรายงานสรุปแยกประเภทของ Correspondence ประจำวัน, สัปดาห์, เดือน, และปีได้ -* 6.4. ประสิทธิภาพ (Performance): มีการใช้ Caching กับข้อมูลที่เรียกใช้บ่อย และใช้ Pagination ในตารางข้อมูลเพื่อจัดการข้อมูลจำนวนมาก -* 6.5. ความปลอดภัย (Security): +- 6.1. การบันทึกการกระทำ (Audit Log): ทุกการกระทำที่สำคัญของผู้ใช้ (สร้าง, แก้ไข, ลบ, ส่ง) จะถูกบันทึกไว้ใน audit_logs เพื่อการตรวจสอบย้อนหลัง +- 6.2. การค้นหา (Search): ระบบต้องมีฟังก์ชันการค้นหาขั้นสูง ที่สามารถค้นหาเอกสารจากหลายเงื่อนไขพร้อมกันได้ เช่น ค้นหาจากชื่อเรื่อง, ประเภท, วันที่, และ Tag +- 6.3. การทำรายงาน (Reporting): สามารถจัดทำรายงานสรุปแยกประเภทของ Correspondence ประจำวัน, สัปดาห์, เดือน, และปีได้ +- 6.4. ประสิทธิภาพ (Performance): มีการใช้ Caching กับข้อมูลที่เรียกใช้บ่อย และใช้ Pagination ในตารางข้อมูลเพื่อจัดการข้อมูลจำนวนมาก +- 6.5. ความปลอดภัย (Security): - มีระบบ Rate Limiting เพื่อป้องกันการโจมตีแบบ Brute-force - การจัดการ Secret (เช่น รหัสผ่าน DB, JWT Secret) จะต้องทำผ่าน Environment Variable ของ Docker เพื่อความปลอดภัยสูงสุด -## 📈 7. +## 📈 7. ## 🧩 8. + 🎯 📤 📊 ✅ 🔄 - - diff --git a/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_1_FullStackJS.md b/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_1_FullStackJS.md index 320dc6f..dc50688 100644 --- a/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_1_FullStackJS.md +++ b/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_1_FullStackJS.md @@ -5,195 +5,195 @@ แนวทางปฏิบัติที่ดีที่สุดแบบครบวงจรสำหรับการพัฒนา **NestJS Backend**, **NextJS Frontend** และ **Tailwind-based UI/UX** ในสภาพแวดล้อม TypeScript มุ่งเน้นที่ **ความชัดเจน (clarity)**, **ความง่ายในการบำรุงรักษา (maintainability)**, **ความสอดคล้องกัน (consistency)** และ **การเข้าถึงได้ (accessibility)** ตลอดทั้งสแต็ก ------ +--- ## ⚙️ แนวทางทั่วไปสำหรับ TypeScript ### หลักการพื้นฐาน - - ใช้ **ภาษาอังกฤษ** สำหรับโค้ดและเอกสารทั้งหมด - - กำหนดไทป์ (type) อย่างชัดเจนสำหรับตัวแปร, พารามิเตอร์ และค่าที่ส่งกลับ (return values) ทั้งหมด - - หลีกเลี่ยงการใช้ `any`; ให้สร้างไทป์ (types) หรืออินเทอร์เฟซ (interfaces) ที่กำหนดเอง - - ใช้ **JSDoc** สำหรับคลาส (classes) และเมธอด (methods) ที่เป็น public - - ส่งออก (Export) **สัญลักษณ์หลัก (main symbol) เพียงหนึ่งเดียว** ต่อไฟล์ - - หลีกเลี่ยงบรรทัดว่างภายในฟังก์ชัน +- ใช้ **ภาษาอังกฤษ** สำหรับโค้ดและเอกสารทั้งหมด +- กำหนดไทป์ (type) อย่างชัดเจนสำหรับตัวแปร, พารามิเตอร์ และค่าที่ส่งกลับ (return values) ทั้งหมด +- หลีกเลี่ยงการใช้ `any`; ให้สร้างไทป์ (types) หรืออินเทอร์เฟซ (interfaces) ที่กำหนดเอง +- ใช้ **JSDoc** สำหรับคลาส (classes) และเมธอด (methods) ที่เป็น public +- ส่งออก (Export) **สัญลักษณ์หลัก (main symbol) เพียงหนึ่งเดียว** ต่อไฟล์ +- หลีกเลี่ยงบรรทัดว่างภายในฟังก์ชัน ### ข้อตกลงในการตั้งชื่อ (Naming Conventions) -| Entity (สิ่งที่ตั้งชื่อ) | Convention (รูปแบบ) | Example (ตัวอย่าง) | -|:--|:--|:--| -| Classes | PascalCase | `UserService` | -| Variables & Functions | camelCase | `getUserInfo` | -| Files & Folders | kebab-case | `user-service.ts` | -| Environment Variables | UPPERCASE | `DATABASE_URL` | -| Booleans | Verb + Noun | `isActive`, `canDelete`, `hasPermission` | +| Entity (สิ่งที่ตั้งชื่อ) | Convention (รูปแบบ) | Example (ตัวอย่าง) | +| :----------------------- | :------------------ | :--------------------------------------- | +| Classes | PascalCase | `UserService` | +| Variables & Functions | camelCase | `getUserInfo` | +| Files & Folders | kebab-case | `user-service.ts` | +| Environment Variables | UPPERCASE | `DATABASE_URL` | +| Booleans | Verb + Noun | `isActive`, `canDelete`, `hasPermission` | ใช้คำเต็ม — ไม่ใช้อักษรย่อ — ยกเว้นคำมาตรฐาน (เช่น `API`, `URL`, `req`, `res`, `err`, `ctx`) ------ +--- ## 🧩 ฟังก์ชัน (Functions) - - เขียนฟังก์ชันให้สั้น และทำ **หน้าที่เพียงอย่างเดียว** (single-purpose) (\< 20 บรรทัด) - - ใช้ **early returns** เพื่อลดการซ้อน (nesting) ของโค้ด - - ใช้ **map**, **filter**, **reduce** แทนการใช้ loops เมื่อเหมาะสม - - ควรใช้ **arrow functions** สำหรับตรรกะสั้นๆ, และใช้ **named functions** ในกรณีอื่น - - ใช้ **default parameters** แทนการตรวจสอบค่า null - - จัดกลุ่มพารามิเตอร์หลายตัวให้เป็นอ็อบเจกต์เดียว (RO-RO pattern) - - ส่งค่ากลับ (Return) เป็นอ็อบเจกต์ที่มีไทป์กำหนด (typed objects) ไม่ใช่ค่าพื้นฐาน (primitives) - - รักษาระดับของสิ่งที่เป็นนามธรรม (abstraction level) ให้เป็นระดับเดียวในแต่ละฟังก์ชัน +- เขียนฟังก์ชันให้สั้น และทำ **หน้าที่เพียงอย่างเดียว** (single-purpose) (\< 20 บรรทัด) +- ใช้ **early returns** เพื่อลดการซ้อน (nesting) ของโค้ด +- ใช้ **map**, **filter**, **reduce** แทนการใช้ loops เมื่อเหมาะสม +- ควรใช้ **arrow functions** สำหรับตรรกะสั้นๆ, และใช้ **named functions** ในกรณีอื่น +- ใช้ **default parameters** แทนการตรวจสอบค่า null +- จัดกลุ่มพารามิเตอร์หลายตัวให้เป็นอ็อบเจกต์เดียว (RO-RO pattern) +- ส่งค่ากลับ (Return) เป็นอ็อบเจกต์ที่มีไทป์กำหนด (typed objects) ไม่ใช่ค่าพื้นฐาน (primitives) +- รักษาระดับของสิ่งที่เป็นนามธรรม (abstraction level) ให้เป็นระดับเดียวในแต่ละฟังก์ชัน ------ +--- ## 🧱 การจัดการข้อมูล (Data Handling) - - ห่อหุ้มข้อมูล (Encapsulate) ในไทป์แบบผสม (composite types) - - ใช้ **immutability** (การไม่เปลี่ยนแปลงค่า) ด้วย `readonly` และ `as const` - - ทำการตรวจสอบความถูกต้องของข้อมูล (Validations) ในคลาสหรือ DTOs ไม่ใช่ภายในฟังก์ชันทางธุรกิจ - - ตรวจสอบความถูกต้องของข้อมูลโดยใช้ DTOs ที่มีไทป์กำหนดเสมอ +- ห่อหุ้มข้อมูล (Encapsulate) ในไทป์แบบผสม (composite types) +- ใช้ **immutability** (การไม่เปลี่ยนแปลงค่า) ด้วย `readonly` และ `as const` +- ทำการตรวจสอบความถูกต้องของข้อมูล (Validations) ในคลาสหรือ DTOs ไม่ใช่ภายในฟังก์ชันทางธุรกิจ +- ตรวจสอบความถูกต้องของข้อมูลโดยใช้ DTOs ที่มีไทป์กำหนดเสมอ ------ +--- ## 🧰 คลาส (Classes) - - ปฏิบัติตามหลักการ **SOLID** - - ควรใช้ **composition มากกว่า inheritance** (Prefer composition over inheritance) - - กำหนด **interfaces** สำหรับสัญญา (contracts) - - ให้คลาสมุ่งเน้นการทำงานเฉพาะอย่างและมีขนาดเล็ก (\< 200 บรรทัด, \< 10 เมธอด, \< 10 properties) +- ปฏิบัติตามหลักการ **SOLID** +- ควรใช้ **composition มากกว่า inheritance** (Prefer composition over inheritance) +- กำหนด **interfaces** สำหรับสัญญา (contracts) +- ให้คลาสมุ่งเน้นการทำงานเฉพาะอย่างและมีขนาดเล็ก (\< 200 บรรทัด, \< 10 เมธอด, \< 10 properties) ------ +--- ## 🚨 การจัดการข้อผิดพลาด (Error Handling) - - ใช้ Exceptions สำหรับข้อผิดพลาดที่ไม่คาดคิด - - ดักจับ (Catch) ข้อผิดพลาดเพื่อแก้ไขหรือเพิ่มบริบท (context) เท่านั้น; หากไม่เช่นนั้น ให้ใช้ global error handlers - - ระบุข้อความข้อผิดพลาด (error messages) ที่มีความหมายเสมอ +- ใช้ Exceptions สำหรับข้อผิดพลาดที่ไม่คาดคิด +- ดักจับ (Catch) ข้อผิดพลาดเพื่อแก้ไขหรือเพิ่มบริบท (context) เท่านั้น; หากไม่เช่นนั้น ให้ใช้ global error handlers +- ระบุข้อความข้อผิดพลาด (error messages) ที่มีความหมายเสมอ ------ +--- ## 🧪 การทดสอบ (ทั่วไป) (Testing (General)) - - ใช้รูปแบบ **Arrange–Act–Assert** - - ใช้ชื่อตัวแปรในการทดสอบที่สื่อความหมาย (`inputData`, `expectedOutput`) - - เขียน **unit tests** สำหรับ public methods ทั้งหมด - - จำลอง (Mock) การพึ่งพาภายนอก (external dependencies) - - เพิ่ม **acceptance tests** ต่อโมดูลโดยใช้รูปแบบ Given–When–Then +- ใช้รูปแบบ **Arrange–Act–Assert** +- ใช้ชื่อตัวแปรในการทดสอบที่สื่อความหมาย (`inputData`, `expectedOutput`) +- เขียน **unit tests** สำหรับ public methods ทั้งหมด +- จำลอง (Mock) การพึ่งพาภายนอก (external dependencies) +- เพิ่ม **acceptance tests** ต่อโมดูลโดยใช้รูปแบบ Given–When–Then ------ +--- # 🏗️ แบ็กเอนด์ (NestJS) (Backend (NestJS)) ### หลักการ - - **สถาปัตยกรรมแบบโมดูลาร์ (Modular architecture)**: - - หนึ่งโมดูลต่อหนึ่งโดเมน - - โครงสร้างแบบ Controller → Service → Repository (Model) - - DTOs ที่ตรวจสอบความถูกต้องด้วย **class-validator** - - ใช้ **MikroORM** (หรือ TypeORM/Prisma) สำหรับการคงอยู่ของข้อมูล (persistence) ซึ่งสอดคล้องกับสคีมา MariaDB - - ห่อหุ้มโค้ดที่ใช้ซ้ำได้ไว้ใน **common module** (`@app/common`): - - Configs, decorators, DTOs, guards, interceptors, notifications, shared services, types, validators +- **สถาปัตยกรรมแบบโมดูลาร์ (Modular architecture)**: + - หนึ่งโมดูลต่อหนึ่งโดเมน + - โครงสร้างแบบ Controller → Service → Repository (Model) +- DTOs ที่ตรวจสอบความถูกต้องด้วย **class-validator** +- ใช้ **MikroORM** (หรือ TypeORM/Prisma) สำหรับการคงอยู่ของข้อมูล (persistence) ซึ่งสอดคล้องกับสคีมา MariaDB +- ห่อหุ้มโค้ดที่ใช้ซ้ำได้ไว้ใน **common module** (`@app/common`): + - Configs, decorators, DTOs, guards, interceptors, notifications, shared services, types, validators ### ฟังก์ชันหลัก (Core Functionalities) - - Global **filters** สำหรับการจัดการ exception - - **Middlewares** สำหรับการจัดการ request - - **Guards** สำหรับการอนุญาต (permissions) และ RBAC - - **Interceptors** สำหรับการแปลงข้อมูล response และการบันทึก log +- Global **filters** สำหรับการจัดการ exception +- **Middlewares** สำหรับการจัดการ request +- **Guards** สำหรับการอนุญาต (permissions) และ RBAC +- **Interceptors** สำหรับการแปลงข้อมูล response และการบันทึก log ### ข้อจำกัดในการ Deploy (QNAP Container Station) - - **ห้ามใช้ไฟล์ `.env`** ในการตั้งค่า Environment Variables - - การตั้งค่าทั้งหมด (เช่น Database connection string, JWT secret) **จะต้องถูกกำหนดผ่าน Environment Variable ใน `docker-compose.yml` โดยตรง** ซึ่งจะจัดการผ่าน UI ของ QNAP Container Station +- **ห้ามใช้ไฟล์ `.env`** ในการตั้งค่า Environment Variables +- การตั้งค่าทั้งหมด (เช่น Database connection string, JWT secret) **จะต้องถูกกำหนดผ่าน Environment Variable ใน `docker-compose.yml` โดยตรง** ซึ่งจะจัดการผ่าน UI ของ QNAP Container Station ### โครงสร้างโมดูลตามโดเมน (Domain-Driven Module Structure) เพื่อให้สอดคล้องกับสคีมา SQL (LCBP3-DMS) เราจะใช้โครงสร้างโมดูลแบบ **Domain-Driven (แบ่งตามขอบเขตธุรกิจ)** แทนการแบ่งตามฟังก์ชัน: 1. **CoreModule / CommonModule:** - * เก็บ Services ที่ใช้ร่วมกัน เช่น `DatabaseModule`, `FileStorageService` (จัดการไฟล์ใน QNAP), `AuditLogService`, และ `NotificationService` + - เก็บ Services ที่ใช้ร่วมกัน เช่น `DatabaseModule`, `FileStorageService` (จัดการไฟล์ใน QNAP), `AuditLogService`, และ `NotificationService` 2. **AuthModule / UserModule:** - * จัดการ `users`, `roles`, `permissions` และการยืนยันตัวตน (JWT, Guards) - * **(สำคัญ)** ต้องรับผิดชอบการตรวจสอบสิทธิ์ **3 ระดับ**: สิทธิ์ระดับระบบ (Global Role), สิทธิ์ระดับโปรเจกต์ (Project Role), และ **สิทธิ์ระดับสัญญา (Contract Role)** - * **(สำคัญ)** ต้องมี API สำหรับ Admin เพื่อ **สร้างและจัดการ Role และการจับคู่ Permission แบบไดนามิก** (ไม่ใช่แค่ seed ข้อมูลเริ่มต้น) + - จัดการ `users`, `roles`, `permissions` และการยืนยันตัวตน (JWT, Guards) + - **(สำคัญ)** ต้องรับผิดชอบการตรวจสอบสิทธิ์ **3 ระดับ**: สิทธิ์ระดับระบบ (Global Role), สิทธิ์ระดับโปรเจกต์ (Project Role), และ **สิทธิ์ระดับสัญญา (Contract Role)** + - **(สำคัญ)** ต้องมี API สำหรับ Admin เพื่อ **สร้างและจัดการ Role และการจับคู่ Permission แบบไดนามิก** (ไม่ใช่แค่ seed ข้อมูลเริ่มต้น) 3. **ProjectModule:** - * จัดการ `projects`, `organizations`, `contracts`, `project_parties`, `contract_parties` + - จัดการ `projects`, `organizations`, `contracts`, `project_parties`, `contract_parties` 4. **CorrespondenceModule (โมดูลศูนย์กลาง):** - * จัดการ `correspondences`, `correspondence_revisions` - * จัดการ `correspondence_attachments` (ตารางเชื่อมไฟล์แนบ) - * รับผิดชอบเวิร์กโฟลว์ **"Correspondence Routings"** (`correspondence_routings`) สำหรับการส่งต่อเอกสารทั่วไประหว่างองค์กร + - จัดการ `correspondences`, `correspondence_revisions` + - จัดการ `correspondence_attachments` (ตารางเชื่อมไฟล์แนบ) + - รับผิดชอบเวิร์กโฟลว์ **"Correspondence Routings"** (`correspondence_routings`) สำหรับการส่งต่อเอกสารทั่วไประหว่างองค์กร 5. **RfaModule:** - * จัดการ `rfas`, `rfa_revisions`, `rfa_items` - * รับผิดชอบเวิร์กโฟลว์ **"RFA Workflows"** (`rfa_workflows`) สำหรับการอนุมัติเอกสารทางเทคนิค + - จัดการ `rfas`, `rfa_revisions`, `rfa_items` + - รับผิดชอบเวิร์กโฟลว์ **"RFA Workflows"** (`rfa_workflows`) สำหรับการอนุมัติเอกสารทางเทคนิค 6. **DrawingModule:** - * จัดการ `shop_drawings`, `shop_drawing_revisions`, `contract_drawings` และหมวดหมู่ต่างๆ - * จัดการ `shop_drawing_revision_attachments` (ตารางเชื่อมไฟล์แนบ) + - จัดการ `shop_drawings`, `shop_drawing_revisions`, `contract_drawings` และหมวดหมู่ต่างๆ + - จัดการ `shop_drawing_revision_attachments` (ตารางเชื่อมไฟล์แนบ) 7. **CirculationModule:** - * จัดการ `circulations`, `circulation_templates`, `circulation_assignees` - * จัดการ `circulation_attachments` (ตารางเชื่อมไฟล์แนบ) - * รับผิดชอบเวิร์กโฟลGว์ **"Circulations"** สำหรับการเวียนเอกสาร **ภายในองค์กร** + - จัดการ `circulations`, `circulation_templates`, `circulation_assignees` + - จัดการ `circulation_attachments` (ตารางเชื่อมไฟล์แนบ) + - รับผิดชอบเวิร์กโฟลGว์ **"Circulations"** สำหรับการเวียนเอกสาร **ภายในองค์กร** 8. **TransmittalModule:** - * จัดการ `transmittals` และ `transmittal_items` + - จัดการ `transmittals` และ `transmittal_items` 9. **SearchModule:** - * **(สำหรับ V1)** ให้บริการค้นหาขั้นสูง (Advanced Search) โดยต้องรองรับการกรองจาก ชื่อเรื่อง (LIKE), ประเภท, วันที่, และ **Tags** (ผ่านการ Join ตาราง) โดยค้นหาผ่าน Views (`v_current_rfas`, `v_current_correspondences`) + - **(สำหรับ V1)** ให้บริการค้นหาขั้นสูง (Advanced Search) โดยต้องรองรับการกรองจาก ชื่อเรื่อง (LIKE), ประเภท, วันที่, และ **Tags** (ผ่านการ Join ตาราง) โดยค้นหาผ่าน Views (`v_current_rfas`, `v_current_correspondences`) ### เครื่องมือและไลบรารีที่แนะนำ (Recommended Tools & Libraries) 🔐 **Authentication & Authorization** - * `@nestjs/passport` - * `@nestjs/jwt` - * `casl` – สำหรับ RBAC (Role-Based Access Control) +- `@nestjs/passport` +- `@nestjs/jwt` +- `casl` – สำหรับ RBAC (Role-Based Access Control) 🗃️ **Database & ORM** - * `@nestjs/typeorm` – ORM สำหรับ SQL (หรือ `Prisma` เป็นทางเลือก) - * `typeorm-seeding` – สำหรับสร้างข้อมูลจำลอง (seeding) +- `@nestjs/typeorm` – ORM สำหรับ SQL (หรือ `Prisma` เป็นทางเลือก) +- `typeorm-seeding` – สำหรับสร้างข้อมูลจำลอง (seeding) 📦 **Validation & Transformation** - * `class-validator` - * `class-transformer` +- `class-validator` +- `class-transformer` 📁 **File Upload & Storage** - * `@nestjs/platform-express` - * `multer` – สำหรับจัดการไฟล์ +- `@nestjs/platform-express` +- `multer` – สำหรับจัดการไฟล์ 🔍 **Search** - * **(สำหรับ V1)** เน้นการค้นหาขั้นสูงตาม Requirement 6.2 (Full-text search/Elasticsearch จะพิจารณาใน V2) +- **(สำหรับ V1)** เน้นการค้นหาขั้นสูงตาม Requirement 6.2 (Full-text search/Elasticsearch จะพิจารณาใน V2) 📬 **Notification** - * `nodemailer` – สำหรับส่งอีเมล - * `@nestjs/schedule` – สำหรับ cron job หรือแจ้งเตือนตามเวลา +- `nodemailer` – สำหรับส่งอีเมล +- `@nestjs/schedule` – สำหรับ cron job หรือแจ้งเตือนตามเวลา 📊 **Logging & Monitoring** - * `winston` หรือ `nestjs-pino` – ระบบ log ที่ยืดหยุ่น - * `@nestjs/terminus` – สำหรับ health check +- `winston` หรือ `nestjs-pino` – ระบบ log ที่ยืดหยุ่น +- `@nestjs/terminus` – สำหรับ health check 🧪 **Testing** - * `@nestjs/testing` - * `jest` – สำหรับ unit/integration test +- `@nestjs/testing` +- `jest` – สำหรับ unit/integration test 🌐 **API Documentation** - * `@nestjs/swagger` – **(สำคัญมาก)** สร้าง Swagger UI อัตโนมัติ ต้องใช้ DTOs อย่างเคร่งครัดเพื่อความชัดเจนของ API สำหรับทีม Frontend +- `@nestjs/swagger` – **(สำคัญมาก)** สร้าง Swagger UI อัตโนมัติ ต้องใช้ DTOs อย่างเคร่งครัดเพื่อความชัดเจนของ API สำหรับทีม Frontend 🛡️ **Security** - * `helmet` – ป้องกันช่องโหว่ HTTP - * `rate-limiter-flexible` – ป้องกัน brute force +- `helmet` – ป้องกันช่องโหว่ HTTP +- `rate-limiter-flexible` – ป้องกัน brute force ### การทดสอบ (Testing) - - ใช้ **Jest** สำหรับการทดสอบ - - ทดสอบทุก controller และ service - - เพิ่ม endpoint `admin/test` เพื่อใช้เป็น smoke test +- ใช้ **Jest** สำหรับการทดสอบ +- ทดสอบทุก controller และ service +- เพิ่ม endpoint `admin/test` เพื่อใช้เป็น smoke test ------ +--- # 🖥️ ฟรอนต์เอนด์ (NextJS / React / UI) (Frontend (NextJS / React / UI)) @@ -204,47 +204,47 @@ ### แนวทางการพัฒนาโค้ด (Code Implementation Guidelines) - - ใช้ **early returns** เพื่อความชัดเจน - - ใช้คลาสของ **TailwindCSS** ในการกำหนดสไตล์เสมอ - - ควรใช้ `class:` syntax แบบมีเงื่อนไข (หรือ utility `clsx`) มากกว่าการใช้ ternary operators ใน class strings - - ใช้ **const arrow functions** สำหรับ components และ handlers - - Event handlers ให้ขึ้นต้นด้วย `handle...` (เช่น `handleClick`, `handleSubmit`) - - รวมแอตทริบิวต์สำหรับการเข้าถึง (accessibility) ด้วย: - `tabIndex="0"`, `aria-label`, `onKeyDown`, ฯลฯ - - ตรวจสอบให้แน่ใจว่าโค้ดทั้งหมด **สมบูรณ์**, **ผ่านการทดสอบ**, และ **ไม่ซ้ำซ้อน (DRY)** - - ต้อง import โมดูลที่จำเป็นต้องใช้อย่างชัดเจนเสมอ +- ใช้ **early returns** เพื่อความชัดเจน +- ใช้คลาสของ **TailwindCSS** ในการกำหนดสไตล์เสมอ +- ควรใช้ `class:` syntax แบบมีเงื่อนไข (หรือ utility `clsx`) มากกว่าการใช้ ternary operators ใน class strings +- ใช้ **const arrow functions** สำหรับ components และ handlers +- Event handlers ให้ขึ้นต้นด้วย `handle...` (เช่น `handleClick`, `handleSubmit`) +- รวมแอตทริบิวต์สำหรับการเข้าถึง (accessibility) ด้วย: + `tabIndex="0"`, `aria-label`, `onKeyDown`, ฯลฯ +- ตรวจสอบให้แน่ใจว่าโค้ดทั้งหมด **สมบูรณ์**, **ผ่านการทดสอบ**, และ **ไม่ซ้ำซ้อน (DRY)** +- ต้อง import โมดูลที่จำเป็นต้องใช้อย่างชัดเจนเสมอ ### UI/UX ด้วย React - - ใช้ **semantic HTML** - - ใช้คลาสของ **Tailwind** ที่รองรับ responsive (`sm:`, `md:`, `lg:`) - - รักษาลำดับชั้นของการมองเห็น (visual hierarchy) ด้วยการใช้ typography และ spacing - - ใช้ **Shadcn** components (Button, Input, Card, ฯลฯ) เพื่อ UI ที่สอดคล้องกัน - - ทำให้ components มีขนาดเล็กและมุ่งเน้นการทำงานเฉพาะอย่าง - - ใช้ utility classes สำหรับการจัดสไตล์อย่างรวดเร็ว (spacing, colors, text, ฯลฯ) - - ตรวจสอบให้แน่ใจว่าสอดคล้องกับ **ARIA** และใช้ semantic markup +- ใช้ **semantic HTML** +- ใช้คลาสของ **Tailwind** ที่รองรับ responsive (`sm:`, `md:`, `lg:`) +- รักษาลำดับชั้นของการมองเห็น (visual hierarchy) ด้วยการใช้ typography และ spacing +- ใช้ **Shadcn** components (Button, Input, Card, ฯลฯ) เพื่อ UI ที่สอดคล้องกัน +- ทำให้ components มีขนาดเล็กและมุ่งเน้นการทำงานเฉพาะอย่าง +- ใช้ utility classes สำหรับการจัดสไตล์อย่างรวดเร็ว (spacing, colors, text, ฯลฯ) +- ตรวจสอบให้แน่ใจว่าสอดคล้องกับ **ARIA** และใช้ semantic markup ### การตรวจสอบฟอร์มและข้อผิดพลาด (Form Validation & Errors) - - ใช้ไลบรารีฝั่ง client เช่น `zod` และ `react-hook-form` - - แสดงข้อผิดพลาดด้วย **alert components** หรือข้อความ inline - - ต้องมี labels, placeholders, และข้อความ feedback +- ใช้ไลบรารีฝั่ง client เช่น `zod` และ `react-hook-form` +- แสดงข้อผิดพลาดด้วย **alert components** หรือข้อความ inline +- ต้องมี labels, placeholders, และข้อความ feedback ------ +--- # 🔗 แนวทางการบูรณาการ Full Stack (Full Stack Integration Guidelines) -| Aspect (แง่มุม) | Backend (NestJS) | Frontend (NextJS) | UI Layer (Tailwind/Shadcn) | -|:--|:--|:--|:--| -| API | REST / GraphQL Controllers | API hooks ผ่าน fetch/axios/SWR | Components ที่รับข้อมูล | -| Validation (การตรวจสอบ) | `class-validator` DTOs | `zod` / `react-hook-form` | สถานะของฟอร์ม/input ใน Shadcn | -| Auth (การยืนยันตัวตน) | Guards, JWT | NextAuth / cookies | สถานะ UI ของ Auth (loading, signed in) | -| Errors (ข้อผิดพลาด) | Global filters | Toasts / modals | Alerts / ข้อความ feedback | -| Testing (การทดสอบ) | Jest (unit/e2e) | Vitest / Playwright | Visual regression | -| Styles (สไตล์) | Scoped modules (ถ้าจำเป็น) | Tailwind / Shadcn | Tailwind utilities | -| Accessibility (การเข้าถึง) | Guards + filters | ARIA attributes | Semantic HTML | +| Aspect (แง่มุม) | Backend (NestJS) | Frontend (NextJS) | UI Layer (Tailwind/Shadcn) | +| :------------------------- | :------------------------- | :----------------------------- | :------------------------------------- | +| API | REST / GraphQL Controllers | API hooks ผ่าน fetch/axios/SWR | Components ที่รับข้อมูล | +| Validation (การตรวจสอบ) | `class-validator` DTOs | `zod` / `react-hook-form` | สถานะของฟอร์ม/input ใน Shadcn | +| Auth (การยืนยันตัวตน) | Guards, JWT | NextAuth / cookies | สถานะ UI ของ Auth (loading, signed in) | +| Errors (ข้อผิดพลาด) | Global filters | Toasts / modals | Alerts / ข้อความ feedback | +| Testing (การทดสอบ) | Jest (unit/e2e) | Vitest / Playwright | Visual regression | +| Styles (สไตล์) | Scoped modules (ถ้าจำเป็น) | Tailwind / Shadcn | Tailwind utilities | +| Accessibility (การเข้าถึง) | Guards + filters | ARIA attributes | Semantic HTML | ------ +--- # 🗂️ ข้อตกลงเฉพาะสำหรับ DMS (LCBP3-DMS) @@ -270,15 +270,15 @@ src/ ### ข้อตกลงการตั้งชื่อ (Naming Convention) -| Entity (สิ่งที่ตั้งชื่อ) | Example (ตัวอย่างจาก SQL) | -|:--|:--| -| Table | `correspondences`, `rfa_revisions`, `contract_parties` | -| Column | `correspondence_id`, `created_by`, `is_current` | -| DTO | `CreateRfaDto`, `UpdateCorrespondenceDto` | -| Controller | `rfas.controller.ts` | -| Service | `correspondences.service.ts` | +| Entity (สิ่งที่ตั้งชื่อ) | Example (ตัวอย่างจาก SQL) | +| :----------------------- | :----------------------------------------------------- | +| Table | `correspondences`, `rfa_revisions`, `contract_parties` | +| Column | `correspondence_id`, `created_by`, `is_current` | +| DTO | `CreateRfaDto`, `UpdateCorrespondenceDto` | +| Controller | `rfas.controller.ts` | +| Service | `correspondences.service.ts` | ------ +--- ## 🧩 RBAC และการควบคุมสิทธิ์ (RBAC & Permission Control) @@ -294,40 +294,40 @@ updateRFA(@Param('id') id: string) { ### Roles (บทบาท) - - **Superadmin**: ไม่มีข้อจำกัดใดๆ - - **Admin**: มีสิทธิ์เต็มที่ในองค์กร - - **Document Control**: เพิ่ม/แก้ไข/ลบ เอกสารในองค์กร - - **Editor**: สามารถ เพิ่ม/แก้ไข เอกสารที่กำหนด - - **Viewer**: สามารถดู เอกสาร +- **Superadmin**: ไม่มีข้อจำกัดใดๆ +- **Admin**: มีสิทธิ์เต็มที่ในองค์กร +- **Document Control**: เพิ่ม/แก้ไข/ลบ เอกสารในองค์กร +- **Editor**: สามารถ เพิ่ม/แก้ไข เอกสารที่กำหนด +- **Viewer**: สามารถดู เอกสาร ### ตัวอย่าง Permissions (จากตาราง `permissions`) - - `rfas.view`, `rfas.create`, `rfas.respond`, `rfas.delete` - - `drawings.view`, `drawings.upload`, `drawings.delete` - - `corr.view`, `corr.manage` - - `transmittals.manage` - - `cirs.manage` - - `project_parties.manage` +- `rfas.view`, `rfas.create`, `rfas.respond`, `rfas.delete` +- `drawings.view`, `drawings.upload`, `drawings.delete` +- `corr.view`, `corr.manage` +- `transmittals.manage` +- `cirs.manage` +- `project_parties.manage` การจับคู่ระหว่าง roles และ permissions **เริ่มต้น** จะถูก seed ผ่านสคริปต์ (ดังที่เห็นในไฟล์ SQL) **อย่างไรก็ตาม `AuthModule`/`UserModule` ต้องมี API สำหรับ Admin เพื่อสร้าง Role ใหม่และกำหนดสิทธิ์ (Permissions) เพิ่มเติมได้ในภายหลัง** ------ +--- ## 🧾 มาตรฐาน AuditLog (AuditLog Standard) บันทึกการดำเนินการ CRUD และการจับคู่ทั้งหมดลงในตาราง `audit_logs` -| Field (ฟิลด์) | Type (จาก SQL) | Description (คำอธิบาย) | -|:--|:--|:--| -| `audit_id` | `BIGINT` | Primary Key | -| `user_id` | `INT` | ผู้ใช้ที่ดำเนินการ (FK -\> users) | -| `action` | `VARCHAR(100)` | `rfa.create`, `correspondence.update`, `login.success` | -| `entity_type`| `VARCHAR(50)` | ชื่อตาราง/โมดูล เช่น 'rfa', 'correspondence' | -| `entity_id` | `VARCHAR(50)` | Primary ID ของระเบียนที่ได้รับผลกระทบ | -| `details_json`| `JSON` | ข้อมูลบริบท (เช่น ฟิลด์ที่มีการเปลี่ยนแปลง) | -| `ip_address` | `VARCHAR(45)` | IP address ของผู้ดำเนินการ | -| `user_agent` | `VARCHAR(255)`| User Agent ของผู้ดำเนินการ | -| `created_at` | `TIMESTAMP` | Timestamp (UTC) | +| Field (ฟิลด์) | Type (จาก SQL) | Description (คำอธิบาย) | +| :------------- | :------------- | :----------------------------------------------------- | +| `audit_id` | `BIGINT` | Primary Key | +| `user_id` | `INT` | ผู้ใช้ที่ดำเนินการ (FK -\> users) | +| `action` | `VARCHAR(100)` | `rfa.create`, `correspondence.update`, `login.success` | +| `entity_type` | `VARCHAR(50)` | ชื่อตาราง/โมดูล เช่น 'rfa', 'correspondence' | +| `entity_id` | `VARCHAR(50)` | Primary ID ของระเบียนที่ได้รับผลกระทบ | +| `details_json` | `JSON` | ข้อมูลบริบท (เช่น ฟิลด์ที่มีการเปลี่ยนแปลง) | +| `ip_address` | `VARCHAR(45)` | IP address ของผู้ดำเนินการ | +| `user_agent` | `VARCHAR(255)` | User Agent ของผู้ดำเนินการ | +| `created_at` | `TIMESTAMP` | Timestamp (UTC) | ตัวอย่างการใช้งาน: @@ -342,24 +342,24 @@ await this.auditLogService.log({ }); ``` ------ +--- ## 📂 การจัดการไฟล์ (File Handling) (ปรับปรุงใหม่) ### มาตรฐานการอัปโหลดไฟล์ (File Upload Standard) - - **ตรรกะใหม่:** การอัปโหลดไฟล์ทั้งหมดจะถูกจัดการโดย `FileStorageService` และบันทึกข้อมูลไฟล์ลงในตาราง `attachments` (ตารางกลาง) - - ไฟล์จะถูกเชื่อมโยงไปยัง Entity ที่ถูกต้องผ่าน **ตารางเชื่อม (Junction Tables)** เท่านั้น: - - `correspondence_attachments` (เชื่อม Correspondence กับ Attachments) - - `circulation_attachments` (เชื่อม Circulation กับ Attachments) - - `shop_drawing_revision_attachments` (เชื่อม Drawing Revision กับ Attachments) - - **(สำคัญ)** คอลัมน์ `file_path` ถูกลบออกจาก `shop_drawing_revisions` แล้ว ต้องใช้ระบบตารางเชื่อมใหม่นี้เท่านั้น - - เส้นทางจัดเก็บไฟล์ (Upload path): อ้างอิงจาก Requirement 2.1 คือ `/share/dms-data` โดย `FileStorageService` จะสร้างโฟลเดอร์ย่อยแบบรวมศูนย์ (เช่น `/share/dms-data/uploads/{YYYY}/{MM}/[stored_filename]`) - - **(หมายเหตุ)**: โครงสร้างนี้ *แทนที่* โครงสร้างแบบแยกโมดูลที่ระบุใน Requirement 3.9 เนื่องจากการออกแบบใหม่ได้รวมศูนย์ไฟล์ไว้ที่ตาราง `attachments` กลางแล้ว - - ประเภทไฟล์ที่อนุญาต: `pdf, dwg, docx, xlsx, zip` - - ขนาดสูงสุด: **50 MB** - - จัดเก็บนอก webroot - - ให้บริการไฟล์ผ่าน endpoint ที่ปลอดภัย `/files/:attachment_id/download` +- **ตรรกะใหม่:** การอัปโหลดไฟล์ทั้งหมดจะถูกจัดการโดย `FileStorageService` และบันทึกข้อมูลไฟล์ลงในตาราง `attachments` (ตารางกลาง) +- ไฟล์จะถูกเชื่อมโยงไปยัง Entity ที่ถูกต้องผ่าน **ตารางเชื่อม (Junction Tables)** เท่านั้น: + - `correspondence_attachments` (เชื่อม Correspondence กับ Attachments) + - `circulation_attachments` (เชื่อม Circulation กับ Attachments) + - `shop_drawing_revision_attachments` (เชื่อม Drawing Revision กับ Attachments) +- **(สำคัญ)** คอลัมน์ `file_path` ถูกลบออกจาก `shop_drawing_revisions` แล้ว ต้องใช้ระบบตารางเชื่อมใหม่นี้เท่านั้น +- เส้นทางจัดเก็บไฟล์ (Upload path): อ้างอิงจาก Requirement 2.1 คือ `/share/dms-data` โดย `FileStorageService` จะสร้างโฟลเดอร์ย่อยแบบรวมศูนย์ (เช่น `/share/dms-data/uploads/{YYYY}/{MM}/[stored_filename]`) + - **(หมายเหตุ)**: โครงสร้างนี้ _แทนที่_ โครงสร้างแบบแยกโมดูลที่ระบุใน Requirement 3.9 เนื่องจากการออกแบบใหม่ได้รวมศูนย์ไฟล์ไว้ที่ตาราง `attachments` กลางแล้ว +- ประเภทไฟล์ที่อนุญาต: `pdf, dwg, docx, xlsx, zip` +- ขนาดสูงสุด: **50 MB** +- จัดเก็บนอก webroot +- ให้บริการไฟล์ผ่าน endpoint ที่ปลอดภัย `/files/:attachment_id/download` ### การควบคุมการเข้าถึง (Access Control) @@ -369,7 +369,7 @@ await this.auditLogService.log({ 2. ตรวจสอบว่า `attachment_id` นี้ เชื่อมโยงกับ Entity ใด (เช่น `correspondence`, `circulation`, `shop_drawing_revision`) ผ่านตารางเชื่อม 3. ตรวจสอบว่าผู้ใช้มีสิทธิ์ (permission) ในการดู Entity ต้นทางนั้นๆ หรือไม่ ------ +--- ## 📊 การรายงานและการส่งออก (Reporting & Exports) @@ -377,80 +377,78 @@ await this.auditLogService.log({ การรายงานควรสร้างขึ้นจาก Views ที่กำหนดไว้ล่วงหน้าในฐานข้อมูลเป็นหลัก: - - `v_current_correspondences`: สำหรับ revision ปัจจุบันทั้งหมดของเอกสารที่ไม่ใช่ RFA - - `v_current_rfas`: สำหรับ revision ปัจจุบันทั้งหมดของ RFA และข้อมูล master - - `v_contract_parties_all`: สำหรับการตรวจสอบความสัมพันธ์ของ project/contract/organization +- `v_current_correspondences`: สำหรับ revision ปัจจุบันทั้งหมดของเอกสารที่ไม่ใช่ RFA +- `v_current_rfas`: สำหรับ revision ปัจจุบันทั้งหมดของ RFA และข้อมูล master +- `v_contract_parties_all`: สำหรับการตรวจสอบความสัมพันธ์ของ project/contract/organization Views เหล่านี้ทำหน้าที่เป็นแหล่งข้อมูลหลักสำหรับการรายงานฝั่งเซิร์ฟเวอร์และการส่งออกข้อมูล ### กฎการส่งออก (Export Rules) - - Export formats: CSV, Excel, PDF. - - จัดเตรียมมุมมองสำหรับพิมพ์ (Print view). - - รวมลิงก์ไปยังต้นทาง (เช่น `/rfas/:id`). +- Export formats: CSV, Excel, PDF. +- จัดเตรียมมุมมองสำหรับพิมพ์ (Print view). +- รวมลิงก์ไปยังต้นทาง (เช่น `/rfas/:id`). ------ +--- ## 🧮 ฟรอนต์เอนด์: รูปแบบ DataTable และฟอร์ม (Frontend: DataTable & Form Patterns) ### DataTable (Server‑Side) - - Endpoint: `/api/{module}?page=1&pageSize=20&sort=...&filter=...` - - ต้องรองรับ: การแบ่งหน้า (pagination), การเรียงลำดับ (sorting), การค้นหา (search), การกรอง (filters) - - แสดง revision ล่าสุดแบบ inline เสมอ (สำหรับ RFA/Drawing) +- Endpoint: `/api/{module}?page=1&pageSize=20&sort=...&filter=...` +- ต้องรองรับ: การแบ่งหน้า (pagination), การเรียงลำดับ (sorting), การค้นหา (search), การกรอง (filters) +- แสดง revision ล่าสุดแบบ inline เสมอ (สำหรับ RFA/Drawing) ### มาตรฐานฟอร์ม (Form Standards) - - ต้องมีการใช้งาน Dropdowns แบบขึ้นต่อกัน (Dependent dropdowns) (ตามที่สคีมารองรับ): - - Project → Contract Drawing Volumes - - Contract Drawing Category → Sub-Category - - RFA (ประเภท Shop Drawing) → Shop Drawing Revisions ที่เชื่อมโยงได้ - - การอัปโหลดไฟล์: ต้องมี preview + validation (ผ่านตรรกะของ `attachments` และตารางเชื่อมใหม่) - - ส่ง (Submit) ผ่าน API พร้อม feedback แบบ toast +- ต้องมีการใช้งาน Dropdowns แบบขึ้นต่อกัน (Dependent dropdowns) (ตามที่สคีมารองรับ): + - Project → Contract Drawing Volumes + - Contract Drawing Category → Sub-Category + - RFA (ประเภท Shop Drawing) → Shop Drawing Revisions ที่เชื่อมโยงได้ +- การอัปโหลดไฟล์: ต้องมี preview + validation (ผ่านตรรกะของ `attachments` และตารางเชื่อมใหม่) +- ส่ง (Submit) ผ่าน API พร้อม feedback แบบ toast ### ข้อกำหนด Component เฉพาะ (Specific UI Requirements) - - **Dashboard - My Tasks:** ต้องพัฒนา Component ตาราง "งานของฉัน" (My Tasks) ซึ่งดึงข้อมูลงานที่ผู้ใช้ล็อกอินอยู่ต้องรับผิดชอบ (Main/Action) จากโมดูล `Circulations` - - **Workflow Visualization:** ต้องพัฒนา Component สำหรับแสดงผล Workflow (โดยเฉพาะ RFA) ที่แสดงขั้นตอนทั้งหมดเป็นลำดับ โดยขั้นตอนปัจจุบัน (active) เท่านั้นที่ดำเนินการได้ และขั้นตอนอื่นเป็น `disabled` ต้องมีตรรกะสำหรับ Admin ในการ override หรือย้อนกลับขั้นตอนได้ +- **Dashboard - My Tasks:** ต้องพัฒนา Component ตาราง "งานของฉัน" (My Tasks) ซึ่งดึงข้อมูลงานที่ผู้ใช้ล็อกอินอยู่ต้องรับผิดชอบ (Main/Action) จากโมดูล `Circulations` +- **Workflow Visualization:** ต้องพัฒนา Component สำหรับแสดงผล Workflow (โดยเฉพาะ RFA) ที่แสดงขั้นตอนทั้งหมดเป็นลำดับ โดยขั้นตอนปัจจุบัน (active) เท่านั้นที่ดำเนินการได้ และขั้นตอนอื่นเป็น `disabled` ต้องมีตรรกะสำหรับ Admin ในการ override หรือย้อนกลับขั้นตอนได้ ------ +--- ## 🧭 แดชบอร์ดและฟีดกิจกรรม (Dashboard & Activity Feed) ### การ์ดบนแดชบอร์ด (Dashboard Cards) - - แสดง Correspondences, RFAs, Circulations ล่าสุด - - รวมสรุป KPI (เช่น "RFAs ที่รอการอนุมัติ") - - รวมลิงก์ด่วนไปยังโมดูลต่างๆ +- แสดง Correspondences, RFAs, Circulations ล่าสุด +- รวมสรุป KPI (เช่น "RFAs ที่รอการอนุมัติ") +- รวมลิงก์ด่วนไปยังโมดูลต่างๆ ### ฟีดกิจกรรม (Activity Feed) - - แสดงรายการ `audit_logs` ล่าสุด (10 รายการ) ที่เกี่ยวข้องกับผู้ใช้ +- แสดงรายการ `audit_logs` ล่าสุด (10 รายการ) ที่เกี่ยวข้องกับผู้ใช้ ```ts // ตัวอย่าง API response -[ - { user: 'editor01', action: 'Updated RFA (LCBP3-RFA-001)', time: '2025-11-04T09:30Z' } -] +[{ user: 'editor01', action: 'Updated RFA (LCBP3-RFA-001)', time: '2025-11-04T09:30Z' }]; ``` ------ +--- ## ✅ มาตรฐานที่นำไปใช้แล้ว (จาก SQL v1.1.0) (Implemented Standards (from SQL v1.1.0)) ส่วนนี้ยืนยันว่าแนวทางปฏิบัติที่ดีที่สุดต่อไปนี้เป็นส่วนหนึ่งของการออกแบบฐานข้อมูลอยู่แล้ว และควรถูกนำไปใช้ประโยชน์ ไม่ใช่สร้างขึ้นใหม่ - - ✅ **Soft Delete:** นำไปใช้แล้วผ่านคอลัมน์ `deleted_at` ในตารางสำคัญ (เช่น `correspondences`, `rfas`, `project_parties`) ตรรกะการดึงข้อมูลต้องกรอง `deleted_at IS NULL` - - ✅ **Database Indexes:** สคีมาได้มีการทำ index ไว้อย่างหนักหน่วงบน foreign keys และคอลัมน์ที่ใช้ค้นหาบ่อย (เช่น `idx_rr_rfa`, `idx_cor_project`, `idx_cr_is_current`) เพื่อประสิทธิภาพ - - ✅ **โครงสร้าง RBAC:** มีระบบ `users`, `roles`, `permissions`, `user_roles`, และ `user_project_roles` ที่ครอบคลุมอยู่แล้ว - - ✅ **Data Seeding:** ข้อมูล Master (roles, permissions, organization\_roles, initial users, project parties) ถูกรวมอยู่ในสคริปต์สคีมาแล้ว +- ✅ **Soft Delete:** นำไปใช้แล้วผ่านคอลัมน์ `deleted_at` ในตารางสำคัญ (เช่น `correspondences`, `rfas`, `project_parties`) ตรรกะการดึงข้อมูลต้องกรอง `deleted_at IS NULL` +- ✅ **Database Indexes:** สคีมาได้มีการทำ index ไว้อย่างหนักหน่วงบน foreign keys และคอลัมน์ที่ใช้ค้นหาบ่อย (เช่น `idx_rr_rfa`, `idx_cor_project`, `idx_cr_is_current`) เพื่อประสิทธิภาพ +- ✅ **โครงสร้าง RBAC:** มีระบบ `users`, `roles`, `permissions`, `user_roles`, และ `user_project_roles` ที่ครอบคลุมอยู่แล้ว +- ✅ **Data Seeding:** ข้อมูล Master (roles, permissions, organization_roles, initial users, project parties) ถูกรวมอยู่ในสคริปต์สคีมาแล้ว ## 🧩 การปรับปรุงที่แนะนำ (สำหรับอนาคต) (Recommended Enhancements (Future)) - - ✅ **(V2)** นำ Fulltext search หรือ Elasticsearch มาใช้กับฟิลด์เช่น `correspondence_revisions.title` หรือ `details` - - ✅ สร้าง Background job (โดยใช้ **n8n** เพื่อเชื่อมต่อกับ **Line** และ/หรือใช้สำหรับการแจ้งเตือน RFA ที่ใกล้ถึงกำหนด `due_date`) - - ✅ เพิ่ม job ล้างข้อมูลเป็นระยะสำหรับ `attachments` ที่ไม่ถูกเชื่อมโยงกับ Entity ใดๆ เลย (ไฟล์กำพร้า) +- ✅ **(V2)** นำ Fulltext search หรือ Elasticsearch มาใช้กับฟิลด์เช่น `correspondence_revisions.title` หรือ `details` +- ✅ สร้าง Background job (โดยใช้ **n8n** เพื่อเชื่อมต่อกับ **Line** และ/หรือใช้สำหรับการแจ้งเตือน RFA ที่ใกล้ถึงกำหนด `due_date`) +- ✅ เพิ่ม job ล้างข้อมูลเป็นระยะสำหรับ `attachments` ที่ไม่ถูกเชื่อมโยงกับ Entity ใดๆ เลย (ไฟล์กำพร้า) ------ \ No newline at end of file +--- diff --git a/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_1_application _requirements.md b/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_1_application _requirements.md index f9d3ecf..86354f1 100644 --- a/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_1_application _requirements.md +++ b/specs/99-archives/docs/Markdown/LCBP3-DMS V1_1_1_application _requirements.md @@ -4,198 +4,188 @@ สร้างเว็บแอปพลิเคชั่นสำหรับระบบบริหารจัดการเอกสารโครงการ (Document Management System) ที่สามารถจัดการและควบคุม การสื่อสารด้วยเอกสารที่ซับซ้อน อย่างมีประสิทธิภาพ - * มีฟังก์ชันหลักในการอัปโหลด จัดเก็บ ค้นหา แชร์ และควบคุมสิทธิ์การเข้าถึงเอกสาร - * ช่วยลดการใช้เอกสารกระดาษ เพิ่มความปลอดภัยในการจัดเก็บข้อมูล - * เพิ่มความสะดวกในการทำงานร่วมกันระหว่างองกรณ์ +- มีฟังก์ชันหลักในการอัปโหลด จัดเก็บ ค้นหา แชร์ และควบคุมสิทธิ์การเข้าถึงเอกสาร +- ช่วยลดการใช้เอกสารกระดาษ เพิ่มความปลอดภัยในการจัดเก็บข้อมูล +- เพิ่มความสะดวกในการทำงานร่วมกันระหว่างองกรณ์ ## 🛠️ 2. สถาปัตยกรรมและเทคโนโลยี (System Architecture & Technology Stack) ใช้สถาปัตยกรรมแบบ Headless/API-First ที่ทันสมัย ทำงานทั้งหมดบน QNAP Server ผ่าน Container Station เพื่อความสะดวกในการจัดการและบำรุงรักษา, Domain: np-dms.work, มี fix ip, รัน docker command ใน application ของ Container Station ได้โดยตรง, ประกอบด้วย - * 2.1. Infrastructure & Environment: - - Server: QNAP (Model: TS-473A, RAM: 32GB, CPU: AMD Ryzen V1500B) - - Containerization: Container Station (Docker & Docker Compose) ใช้ UI ของ Container Station เป็นหลัก ในการ configuration และการรัน docker command - - Development Environment: VS Code on Windows 11 - - Domain: np-dms.work, www.np-dms.work - - ip: 159.192.126.103 - - Docker Network: ทุก Service จะเชื่อมต่อผ่านเครือข่ายกลางชื่อ lcbp3 เพื่อให้สามารถสื่อสารกันได้ - - Data Storage: /share/dms-data บน QNAP - - ข้อจำกัด: ไม่สามารถใช้ .env ในการกำหนดตัวแปรภายนอกได้ ต้องกำหนดใน docker-compose.yml เท่านั้น - * 2.2. Code Hosting: - - Application name: git - - Service: Gitea (Self-hosted on QNAP) - - Service name: gitea - - Domain: git.np-dms.work - - หน้าที่: เป็นศูนย์กลางในการเก็บและจัดการเวอร์ชันของโค้ด (Source Code) สำหรับทุกส่วน - * 2.3. Backend / Data Platform: - - Application name: lcbp3-backend - - Service: NestJS - - Service name: backend - - Domain: backend.np-dms.work - - Framework: NestJS (Node.js, TypeScript, ESM) - - หน้าที่: จัดการโครงสร้างข้อมูล (Data Models), สร้าง API, จัดการสิทธิ์ผู้ใช้ (Roles & Permissions), และสร้าง Workflow ทั้งหมดของระบบ - * 2.4. Database: - - Application name: lcbp3-db - - Service: mariadb:10.11 - - Service name: mariadb - - Domain: db.np-dms.work - - หน้าที่: ฐานข้อมูลหลักสำหรับเก็บข้อมูลทั้งหมด - - Tooling: DBeaver (Community Edition), phpmyadmin สำหรับการออกแบบและจัดการฐานข้อมูล - * 2.5. Database management: - - Application name: lcbp3-db - - Service: phpmyadmin:5-apache - - Service name: pma - - Domain: pma.np-dms.work - - หน้าที่: จัดการฐานข้อมูล mariadb ผ่าน Web UI - * 2.6. Frontend: - - Application name: lcbp3-frontend - - Service: next.js - - Service name: frontend - - Domain: lcbp3.np-dms.work - - Framework: Next.js (App Router, React, TypeScript, ESM) - - Styling: Tailwind CSS + PostCSS - - Component Library: shadcn/ui - - หน้าที่: สร้างหน้าตาเว็บแอปพลิเคชันสำหรับให้ผู้ใช้งานเข้ามาดู Dashboard, จัดการเอกสาร, และติดตามงาน โดยจะสื่อสารกับ Backend ผ่าน API - * 2.7. Workflow automation: - - Application name: lcbp3-n8n - - Service: n8nio/n8n:latest - - Service name: n8n - - Domain: n8n.np-dms.work - - หน้าที่: จัดการ workflow ระหว่าง Backend และ Line - * 2.8. Reverse Proxy: - - Application name: lcbp3-npm - - Service: Nginx Proxy Manager (nginx-proxy-manage: latest) - - Service name: npm - - Domain: npm.np-dms.work - - หน้าที่: เป็นด่านหน้าในการรับ-ส่งข้อมูล จัดการโดเมนทั้งหมด, ทำหน้าที่เป็น Proxy ชี้ไปยัง Service ที่ถูกต้อง, และจัดการ SSL Certificate (HTTPS) ให้อัตโนมัติ +- 2.1. Infrastructure & Environment: + - Server: QNAP (Model: TS-473A, RAM: 32GB, CPU: AMD Ryzen V1500B) + - Containerization: Container Station (Docker & Docker Compose) ใช้ UI ของ Container Station เป็นหลัก ในการ configuration และการรัน docker command + - Development Environment: VS Code on Windows 11 + - Domain: np-dms.work, www.np-dms.work + - ip: 159.192.126.103 + - Docker Network: ทุก Service จะเชื่อมต่อผ่านเครือข่ายกลางชื่อ lcbp3 เพื่อให้สามารถสื่อสารกันได้ + - Data Storage: /share/dms-data บน QNAP + - ข้อจำกัด: ไม่สามารถใช้ .env ในการกำหนดตัวแปรภายนอกได้ ต้องกำหนดใน docker-compose.yml เท่านั้น +- 2.2. Code Hosting: + - Application name: git + - Service: Gitea (Self-hosted on QNAP) + - Service name: gitea + - Domain: git.np-dms.work + - หน้าที่: เป็นศูนย์กลางในการเก็บและจัดการเวอร์ชันของโค้ด (Source Code) สำหรับทุกส่วน +- 2.3. Backend / Data Platform: + - Application name: lcbp3-backend + - Service: NestJS + - Service name: backend + - Domain: backend.np-dms.work + - Framework: NestJS (Node.js, TypeScript, ESM) + - หน้าที่: จัดการโครงสร้างข้อมูล (Data Models), สร้าง API, จัดการสิทธิ์ผู้ใช้ (Roles & Permissions), และสร้าง Workflow ทั้งหมดของระบบ +- 2.4. Database: + - Application name: lcbp3-db + - Service: mariadb:10.11 + - Service name: mariadb + - Domain: db.np-dms.work + - หน้าที่: ฐานข้อมูลหลักสำหรับเก็บข้อมูลทั้งหมด + - Tooling: DBeaver (Community Edition), phpmyadmin สำหรับการออกแบบและจัดการฐานข้อมูล +- 2.5. Database management: + - Application name: lcbp3-db + - Service: phpmyadmin:5-apache + - Service name: pma + - Domain: pma.np-dms.work + - หน้าที่: จัดการฐานข้อมูล mariadb ผ่าน Web UI +- 2.6. Frontend: + - Application name: lcbp3-frontend + - Service: next.js + - Service name: frontend + - Domain: lcbp3.np-dms.work + - Framework: Next.js (App Router, React, TypeScript, ESM) + - Styling: Tailwind CSS + PostCSS + - Component Library: shadcn/ui + - หน้าที่: สร้างหน้าตาเว็บแอปพลิเคชันสำหรับให้ผู้ใช้งานเข้ามาดู Dashboard, จัดการเอกสาร, และติดตามงาน โดยจะสื่อสารกับ Backend ผ่าน API +- 2.7. Workflow automation: + - Application name: lcbp3-n8n + - Service: n8nio/n8n:latest + - Service name: n8n + - Domain: n8n.np-dms.work + - หน้าที่: จัดการ workflow ระหว่าง Backend และ Line +- 2.8. Reverse Proxy: + - Application name: lcbp3-npm + - Service: Nginx Proxy Manager (nginx-proxy-manage: latest) + - Service name: npm + - Domain: npm.np-dms.work + - หน้าที่: เป็นด่านหน้าในการรับ-ส่งข้อมูล จัดการโดเมนทั้งหมด, ทำหน้าที่เป็น Proxy ชี้ไปยัง Service ที่ถูกต้อง, และจัดการ SSL Certificate (HTTPS) ให้อัตโนมัติ ## 📦 3. ข้อกำหนดด้านฟังก์ชันการทำงาน (Functional Requirements) - * 3.1. การจัดการโครงสร้างโครงการและองค์กร +- 3.1. การจัดการโครงสร้างโครงการและองค์กร + - 3.1.1. โครงการ (Projects): ระบบต้องสามารถจัดการเอกสารภายในหลายโครงการได้ (ปัจจุบันมี 4 โครงการ และจะเพิ่มขึ้นในอนาคต) + - 3.1.2. สัญญา (Contracts): ระบบต้องสามารถจัดการเอกสารภายในแต่ละสัญญาได้ ในแต่ละโครงการ มีได้หลายสัญญา หรืออย่างน้อย 1 สัญญา + - 3.1.3. องค์กร (Organizations): + - มีหลายองค์กรในโครงการ องค์กรณ์ที่เป็น Owner, Designer และ Consultant สามารถอยู่ในหลายโครงการและหลายสัญญาได้ + - Contractor จะถือ 1 สัญญา และอยู่ใน 1 โครงการเท่านั้น - - 3.1.1. โครงการ (Projects): ระบบต้องสามารถจัดการเอกสารภายในหลายโครงการได้ (ปัจจุบันมี 4 โครงการ และจะเพิ่มขึ้นในอนาคต) - - 3.1.2. สัญญา (Contracts): ระบบต้องสามารถจัดการเอกสารภายในแต่ละสัญญาได้ ในแต่ละโครงการ มีได้หลายสัญญา หรืออย่างน้อย 1 สัญญา - - 3.1.3. องค์กร (Organizations): - - มีหลายองค์กรในโครงการ องค์กรณ์ที่เป็น Owner, Designer และ Consultant สามารถอยู่ในหลายโครงการและหลายสัญญาได้ - - Contractor จะถือ 1 สัญญา และอยู่ใน 1 โครงการเท่านั้น +- 3.2. การจัดการเอกสารโต้ตอบ (Correspondence Management) + - 3.2.1. วัตถุประสงค์: เอกสารโต้ตอบ (correspondences) ระหว่างองกรณื-องกรณ์ ภายใน โครงการ (Projects) และระหว่าง องค์กร-องค์กร ภายนอก โครงการ (Projects) + - 3.2.2. ประเภทเอกสาร: ระบบต้องรองรับเอกสารรูปแบบ ไฟล์ PDF หลายประเภท (Types) เช่น จดหมาย (Letter), อีเมล์ (Email), Request for Information (RFI), และสามารถเพิ่มประเภทใหม่ได้ในภายหลัง + - 3.2.3. การสร้างเอกสาร (Correspondence): + - ผู้ใช้ที่มีสิทธิ์ (เช่น Document Control) สามารถสร้างเอกสารรอไว้ในสถานะ ฉบับร่าง" (Draft) ได้ ซึ่งผู้ใช้งานต่างองค์กรจะมองไม่เห็น + - เมื่อกด "Submitted" แล้ว การแก้ไข, ถอนเอกสารกลับไปสถานะ Draft, หรือยกเลิก (Cancel) จะต้องทำโดยผู้ใช้ระดับ Admin ขึ้นไป พร้อมระบุเหตุผล + - 3.2.4. การอ้างอิงและจัดกลุ่ม: + - เอกสารสามารถอ้างถึง (Reference) เอกสารฉบับก่อนหน้าได้หลายฉบับ + - สามารถกำหนด Tag ได้หลาย Tag เพื่อจัดกลุ่มและใช้ในการค้นหาขั้นสูง + - 3.2.5. การจัดการ: มีการจัดการอย่างน้อยดังนี้ + - สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบของ องกรณ์ ที่อเป็นผู้รับ ได้ + - มีระบบแจ้งเตือน ให้ผู้รับผิดชอบขององกรณ์ที่เป็น ผู้รับ/ผู้ส่ง ทราบ เมื่อมีเอกสารใหม่ หรือมีการเปลี่ยนสถานะ - * 3.2. การจัดการเอกสารโต้ตอบ (Correspondence Management) +- 3.3. การจัดกาแบบคู่สัญญา (Contract Drawing) + - 3.3.1. วัตถุประสงค์: แบบคู่สัญญา (Contract Drawing) ใช้เพื่ออ้างอิงและใช้ในการตรวจสอบ + - 3.3.2. ประเภทเอกสาร: ไฟล์ PDF + - 3.3.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ + - 3.3.4. การอ้างอิงและจัดกลุ่ม: ใช้สำหรับอ้างอิง ใน Shop Drawings, มีการจัดหมวดหมู่ของ Contract Drawing - - 3.2.1. วัตถุประสงค์: เอกสารโต้ตอบ (correspondences) ระหว่างองกรณื-องกรณ์ ภายใน โครงการ (Projects) และระหว่าง องค์กร-องค์กร ภายนอก โครงการ (Projects) - - 3.2.2. ประเภทเอกสาร: ระบบต้องรองรับเอกสารรูปแบบ ไฟล์ PDF หลายประเภท (Types) เช่น จดหมาย (Letter), อีเมล์ (Email), Request for Information (RFI), และสามารถเพิ่มประเภทใหม่ได้ในภายหลัง - - 3.2.3. การสร้างเอกสาร (Correspondence): - - ผู้ใช้ที่มีสิทธิ์ (เช่น Document Control) สามารถสร้างเอกสารรอไว้ในสถานะ ฉบับร่าง" (Draft) ได้ ซึ่งผู้ใช้งานต่างองค์กรจะมองไม่เห็น - - เมื่อกด "Submitted" แล้ว การแก้ไข, ถอนเอกสารกลับไปสถานะ Draft, หรือยกเลิก (Cancel) จะต้องทำโดยผู้ใช้ระดับ Admin ขึ้นไป พร้อมระบุเหตุผล - - 3.2.4. การอ้างอิงและจัดกลุ่ม: - - เอกสารสามารถอ้างถึง (Reference) เอกสารฉบับก่อนหน้าได้หลายฉบับ - - สามารถกำหนด Tag ได้หลาย Tag เพื่อจัดกลุ่มและใช้ในการค้นหาขั้นสูง - - 3.2.5. การจัดการ: มีการจัดการอย่างน้อยดังนี้ - - สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบของ องกรณ์ ที่อเป็นผู้รับ ได้ - - มีระบบแจ้งเตือน ให้ผู้รับผิดชอบขององกรณ์ที่เป็น ผู้รับ/ผู้ส่ง ทราบ เมื่อมีเอกสารใหม่ หรือมีการเปลี่ยนสถานะ +- 3.4. การจัดกาแบบก่อสร้าง (Shop Drawing) + - 3.4.1. วัตถุประสงค์: แบบก่อสร้าง (Shop Drawing) ใช้เในการตรวจสอบ โดยจัดส่งด้วย Request for Approval (RFA) + - 3.4.2. ประเภทเอกสาร: ไฟล์ PDF + - 3.4.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ + - 3.4.4. การอ้างอิงและจัดกลุ่ม: ช้สำหรับอ้างอิง ใน Shop Drawings, มีการจัดหมวดหมู่ของ Shop Drawings - * 3.3. การจัดกาแบบคู่สัญญา (Contract Drawing) +- 3.5. การจัดการเอกสารขออนุมัติ (Request for Approval & Workflow) + - 3.5.1. วัตถุประสงค์: เอกสารขออนุมัติ (Request for Approval) ใช้ในการส่งเอกสารเพิอขออนุมัติ + - 3.5.2. ประเภทเอกสาร: Request for Approval (RFA) เป็นชนิดหนึ่งของ Correspondence ที่มีลักษณะเฉพาะที่ต้องได้รับการอนุมัติ มีประเภทดังนี้: + - Request for Drawing Approval (RFA_DWG) + - Request for Document Approval (RFA_DOC) + - Request for Method statement Approval (RFA_MES) + - Request for Material Approval (RFA_MAT) + - 3.5.2. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ + - 3.5.4. การอ้างอิงและจัดกลุ่ม: การจัดการ Drawing (RFA_DWG): + - เอกสาร RFA_DWG จะประกอบไปด้วย Shop Drawing (shop_drawings) หลายแผ่น ซึ่งแต่ละแผ่นมี Revision ของตัวเอง + - Shop Drawing แต่ละ Revision สามารถอ้างอิงถึง Contract Drawing (Ccontract_drawings) หลายแผ่น หรือไม่อ้างถึงก็ได้ + - ระบบต้องมีส่วนสำหรับจัดการข้อมูล Master Data ของทั้ง Shop Drawing และ Contract Drawing แยกจากกัน + - 3.6.5. Workflow การอนุมัติ: ต้องรองรับกระบวนการอนุมัติที่ซับซ้อนและเป็นลำดับ เช่น + - ส่งจาก Originator -\> Organization 1 -\> Organization 2 -\> Organization 3 แล้วส่งผลกลับตามลำดับเดิม (โดยถ้า องกรณ์ใดใน Workflow ให้ส่งกลับ ก็สามารถส่งผลกลับตามลำดับเดิมโดยไม่ต้องรอให้ถึง องกรณืในลำดับถัดไป) + - 3.6.6. การจัดการ: มีการจัดการอย่างน้อยดังนี้ + - สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบของ องกรณ์ ที่อยู่ใน Workflow ได้ + - มีระบบแจ้งเตือน ให้ผู้รับผิดชอบของ องกรณ์ ที่อยู่ใน Workflow ทราบ เมื่อมี RFA ใหม่ หรือมีการเปลี่ยนสถานะ - - 3.3.1. วัตถุประสงค์: แบบคู่สัญญา (Contract Drawing) ใช้เพื่ออ้างอิงและใช้ในการตรวจสอบ - - 3.3.2. ประเภทเอกสาร: ไฟล์ PDF - - 3.3.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ - - 3.3.4. การอ้างอิงและจัดกลุ่ม: ใช้สำหรับอ้างอิง ใน Shop Drawings, มีการจัดหมวดหมู่ของ Contract Drawing +- 3.6.การจัดการเอกสารนำส่ง (Transmittals) + - 3.6.1. วัตถุประสงค์: เอกสารนำส่ง ใช้สำหรับ นำส่ง Request for Approval (RFAS) หลายฉบับ ไปยังองค์กรอื่น + - 3.6.2. ประเภทเอกสาร: ไฟล์ PDF + - 3.6.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ + - 3.6.4. การอ้างอิงและจัดกลุ่ม: เอกสารนำส่ง เป็นส่วนหนึ่งใน Correspondence - * 3.4. การจัดกาแบบก่อสร้าง (Shop Drawing) +- 3.7. ใบเวียนเอกสารภายใน (Internal Circulation Sheet) + - 3.7.1. วัตถุประสงค์: การสื่อสาร เอกสาร (Correspondence) ทุกฉบับ จะมีใบเวียนเอกสารเพื่อควบคุมและมอบหมายงานภายในองค์กร (สามารถดูและแก้ไขได้เฉพาะคนในองค์กร) + - 3.7.2. ประเภทเอกสาร: ไฟล์ PDF + - 3.7.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ในองค์กรนั้น สามารถสร้างและแก้ไขได้ + - 3.7.4. การอ้างอิงและจัดกลุ่ม: การระบุผู้รับผิดชอบ: + - ผู้รับผิดชอบหลัก (Main): มีได้หลายคน + - ผู้ร่วมปฏิบัติงาน (Action): มีได้หลายคน + - ผู้ที่ต้องรับทราบ (Information): มีได้หลายคน + - 3.7.5. การติดตามงาน: + - สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบประเภท Main และ Action ได้ + - มีระบบแจ้งเตือนเมื่อมี Circulation ใหม่ และแจ้งเตือนล่วงหน้าก่อนถึงวันแล้วเสร็จ + - สามารถปิด Circulation ได้เมื่อดำเนินการตอบกลับไปยังองค์กรผู้ส่ง (Originator) แล้ว หรือ รับทราบแล้ว (For Information) - - 3.4.1. วัตถุประสงค์: แบบก่อสร้าง (Shop Drawing) ใช้เในการตรวจสอบ โดยจัดส่งด้วย Request for Approval (RFA) - - 3.4.2. ประเภทเอกสาร: ไฟล์ PDF - - 3.4.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ - - 3.4.4. การอ้างอิงและจัดกลุ่ม: ช้สำหรับอ้างอิง ใน Shop Drawings, มีการจัดหมวดหมู่ของ Shop Drawings +- 3.8. ประวัติการแก้ไข (Revisions): ระบบจะเก็บประวัติการสร้างและแก้ไข เอกสารทั้งหมด - * 3.5. การจัดการเอกสารขออนุมัติ (Request for Approval & Workflow) - - - 3.5.1. วัตถุประสงค์: เอกสารขออนุมัติ (Request for Approval) ใช้ในการส่งเอกสารเพิอขออนุมัติ - - 3.5.2. ประเภทเอกสาร: Request for Approval (RFA) เป็นชนิดหนึ่งของ Correspondence ที่มีลักษณะเฉพาะที่ต้องได้รับการอนุมัติ มีประเภทดังนี้: - - Request for Drawing Approval (RFA\_DWG) - - Request for Document Approval (RFA\_DOC) - - Request for Method statement Approval (RFA\_MES) - - Request for Material Approval (RFA\_MAT) - - 3.5.2. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ - - 3.5.4. การอ้างอิงและจัดกลุ่ม: การจัดการ Drawing (RFA\_DWG): - - เอกสาร RFA\_DWG จะประกอบไปด้วย Shop Drawing (shop\_drawings) หลายแผ่น ซึ่งแต่ละแผ่นมี Revision ของตัวเอง - - Shop Drawing แต่ละ Revision สามารถอ้างอิงถึง Contract Drawing (Ccontract\_drawings) หลายแผ่น หรือไม่อ้างถึงก็ได้ - - ระบบต้องมีส่วนสำหรับจัดการข้อมูล Master Data ของทั้ง Shop Drawing และ Contract Drawing แยกจากกัน - - 3.6.5. Workflow การอนุมัติ: ต้องรองรับกระบวนการอนุมัติที่ซับซ้อนและเป็นลำดับ เช่น - - ส่งจาก Originator -\> Organization 1 -\> Organization 2 -\> Organization 3 แล้วส่งผลกลับตามลำดับเดิม (โดยถ้า องกรณ์ใดใน Workflow ให้ส่งกลับ ก็สามารถส่งผลกลับตามลำดับเดิมโดยไม่ต้องรอให้ถึง องกรณืในลำดับถัดไป) - - 3.6.6. การจัดการ: มีการจัดการอย่างน้อยดังนี้ - - สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบของ องกรณ์ ที่อยู่ใน Workflow ได้ - - มีระบบแจ้งเตือน ให้ผู้รับผิดชอบของ องกรณ์ ที่อยู่ใน Workflow ทราบ เมื่อมี RFA ใหม่ หรือมีการเปลี่ยนสถานะ - - * 3.6.การจัดการเอกสารนำส่ง (Transmittals) - - - 3.6.1. วัตถุประสงค์: เอกสารนำส่ง ใช้สำหรับ นำส่ง Request for Approval (RFAS) หลายฉบับ ไปยังองค์กรอื่น - - 3.6.2. ประเภทเอกสาร: ไฟล์ PDF - - 3.6.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ - - 3.6.4. การอ้างอิงและจัดกลุ่ม: เอกสารนำส่ง เป็นส่วนหนึ่งใน Correspondence - - * 3.7. ใบเวียนเอกสารภายใน (Internal Circulation Sheet) - - - 3.7.1. วัตถุประสงค์: การสื่อสาร เอกสาร (Correspondence) ทุกฉบับ จะมีใบเวียนเอกสารเพื่อควบคุมและมอบหมายงานภายในองค์กร (สามารถดูและแก้ไขได้เฉพาะคนในองค์กร) - - 3.7.2. ประเภทเอกสาร: ไฟล์ PDF - - 3.7.3. การสร้างเอกสาร: ผู้ใช้ที่มีสิทธิ์ในองค์กรนั้น สามารถสร้างและแก้ไขได้ - - 3.7.4. การอ้างอิงและจัดกลุ่ม: การระบุผู้รับผิดชอบ: - - ผู้รับผิดชอบหลัก (Main): มีได้หลายคน - - ผู้ร่วมปฏิบัติงาน (Action): มีได้หลายคน - - ผู้ที่ต้องรับทราบ (Information): มีได้หลายคน - - 3.7.5. การติดตามงาน: - - สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบประเภท Main และ Action ได้ - - มีระบบแจ้งเตือนเมื่อมี Circulation ใหม่ และแจ้งเตือนล่วงหน้าก่อนถึงวันแล้วเสร็จ - - สามารถปิด Circulation ได้เมื่อดำเนินการตอบกลับไปยังองค์กรผู้ส่ง (Originator) แล้ว หรือ รับทราบแล้ว (For Information) - - * 3.8. ประวัติการแก้ไข (Revisions): ระบบจะเก็บประวัติการสร้างและแก้ไข เอกสารทั้งหมด - - * **3.9. การจัดเก็บ: (ปรับปรุงตามสถาปัตยกรรมใหม่)** - - - เอกสารและไฟล์แนบทั้งหมดจะถูกจัดเก็บในโฟลเดอร์บน Server (`/share/dms-data/`) - - ข้อมูล Metadata ของไฟล์ (เช่น ชื่อไฟล์, ขนาด, path) จะถูกเก็บในตาราง `attachments` (ตารางกลาง) - - ไฟล์จะถูกเชื่อมโยงกับเอกสารประเภทต่างๆ ผ่านตารางเชื่อม (Junction tables) เช่น `correspondence_attachments`, `circulation_attachments`, และ `shop_drawing_revision_attachments` - - สถาปัตยกรรมแบบรวมศูนย์นี้ *แทนที่* แนวคิดเดิมที่จะแยกโฟลเดอร์ตามประเภทเอกสาร เพื่อรองรับการขยายระบบที่ดีกว่า +- **3.9. การจัดเก็บ: (ปรับปรุงตามสถาปัตยกรรมใหม่)** + - เอกสารและไฟล์แนบทั้งหมดจะถูกจัดเก็บในโฟลเดอร์บน Server (`/share/dms-data/`) + - ข้อมูล Metadata ของไฟล์ (เช่น ชื่อไฟล์, ขนาด, path) จะถูกเก็บในตาราง `attachments` (ตารางกลาง) + - ไฟล์จะถูกเชื่อมโยงกับเอกสารประเภทต่างๆ ผ่านตารางเชื่อม (Junction tables) เช่น `correspondence_attachments`, `circulation_attachments`, และ `shop_drawing_revision_attachments` + - สถาปัตยกรรมแบบรวมศูนย์นี้ _แทนที่_ แนวคิดเดิมที่จะแยกโฟลเดอร์ตามประเภทเอกสาร เพื่อรองรับการขยายระบบที่ดีกว่า ## 🔐 4. ข้อกำหนดด้านสิทธิ์และการเข้าถึง (Access Control Requirements) - * 4.1. ภาพรวม: ผู้ใช้และองค์กรสามารถดูและแก้ไขเอกสารได้ตามสิทธิ์ที่ได้รับ โดยระบบสิทธิ์จะเป็นแบบ Role-Based Access Control (RBAC) +- 4.1. ภาพรวม: ผู้ใช้และองค์กรสามารถดูและแก้ไขเอกสารได้ตามสิทธิ์ที่ได้รับ โดยระบบสิทธิ์จะเป็นแบบ Role-Based Access Control (RBAC) - * 4.2. ระดับของสิทธิ์: +- 4.2. ระดับของสิทธิ์: + - Global Roles: สิทธิ์ในภาพรวมของระบบ + - Project-Specific Roles: สิทธิ์ที่ถูกกำหนดให้ผู้ใช้สำหรับโครงการนั้นๆ โดยเฉพาะ (เช่น เป็น Editor ในโครงการ A แต่เป็น Viewer ในโครงการ B) + - Contract-Specific Roles: สิทธิ์ที่ถูกกำหนดให้โครงการสำหรับสัญญานั้นๆ (เช่น เป็น Admin ในสัญญา 1 จะเป็น Admin ใน โครงการ A และ ฺB ที่อยู่ในสัญญา 1) - - Global Roles: สิทธิ์ในภาพรวมของระบบ - - Project-Specific Roles: สิทธิ์ที่ถูกกำหนดให้ผู้ใช้สำหรับโครงการนั้นๆ โดยเฉพาะ (เช่น เป็น Editor ในโครงการ A แต่เป็น Viewer ในโครงการ B) - - Contract-Specific Roles: สิทธิ์ที่ถูกกำหนดให้โครงการสำหรับสัญญานั้นๆ (เช่น เป็น Admin ในสัญญา 1 จะเป็น Admin ใน โครงการ A และ ฺB ที่อยู่ในสัญญา 1) +- 4.3. บทบาท (Roles) พื้นฐาน: + - Superadmin: ไม่มีข้อจำกัดใดๆ สามารถจัดการได้ทุกอย่างข้ามองค์กร + - Admin: มีสิทธิ์เต็มที่ แต่จำกัดเฉพาะในองค์กรที่ตัวเองสังกัด สามารถจัดการผู้ใช้ในองค์กรได้ สามารถสร้าง Role ใหม่และกำหนดสิทธิ์ (Permissions) เพิ่มเติมได้ในภายหลังผ่านหน้า Admin + - Document Control สามารถ เพิ่ม/แก้ไข/ลบ เอกสาร เฉพาะในองค์กรที่ตัวเองสังกัด ไม่สามารถจัดการผู้ใช้ได้ + - Editor: สามารถ เพิ่ม/แก้ไข เอกสารที่กำหนดไว้ เฉพาะในองค์กรที่ตัวเองสังกัด + - Viewer: สามารถดู เอกสาร เฉพาะในองค์กรที่ตัวเองสังกัด - * 4.3. บทบาท (Roles) พื้นฐาน: - - - Superadmin: ไม่มีข้อจำกัดใดๆ สามารถจัดการได้ทุกอย่างข้ามองค์กร - - Admin: มีสิทธิ์เต็มที่ แต่จำกัดเฉพาะในองค์กรที่ตัวเองสังกัด สามารถจัดการผู้ใช้ในองค์กรได้ สามารถสร้าง Role ใหม่และกำหนดสิทธิ์ (Permissions) เพิ่มเติมได้ในภายหลังผ่านหน้า Admin - - Document Control สามารถ เพิ่ม/แก้ไข/ลบ เอกสาร เฉพาะในองค์กรที่ตัวเองสังกัด ไม่สามารถจัดการผู้ใช้ได้ - - Editor: สามารถ เพิ่ม/แก้ไข เอกสารที่กำหนดไว้ เฉพาะในองค์กรที่ตัวเองสังกัด - - Viewer: สามารถดู เอกสาร เฉพาะในองค์กรที่ตัวเองสังกัด - - * 4.4. การบังคับใช้สิทธิ์: สิทธิ์ขององค์กรจะครอบคลุมสิทธิ์ของผู้ใช้ และการเข้าถึงข้อมูลที่เกี่ยวข้องกับโครงการ (เช่น การแก้ไขเอกสาร) จะถูกตรวจสอบเทียบกับสิทธิ์ที่ผู้ใช้มีในโครงการนั้นๆ โดยเฉพาะ +- 4.4. การบังคับใช้สิทธิ์: สิทธิ์ขององค์กรจะครอบคลุมสิทธิ์ของผู้ใช้ และการเข้าถึงข้อมูลที่เกี่ยวข้องกับโครงการ (เช่น การแก้ไขเอกสาร) จะถูกตรวจสอบเทียบกับสิทธิ์ที่ผู้ใช้มีในโครงการนั้นๆ โดยเฉพาะ ## 👥 5. ข้อกำหนดด้านผู้ใช้งาน (User Interface & Experience) - * 5.1. Layout หลัก: หน้าเว็บใช้รูปแบบ App Shell ที่ประกอบด้วย: - - Navbar (ส่วนบน): แสดงชื่อระบบ, เมนูผู้ใช้ (Profile), เมนูสำหรับ Admin/Superadmin (จัดการผู้ใช้, จัดการสิทธิ์), และปุ่ม Login/Logout - - Sidebar (ด้านข้าง): เป็นเมนูหลักสำหรับเข้าถึงส่วนที่เกี่ยวกับเอกสารทั้งหมด เช่น Dashboard, Correspondences, RFA, Drawings - - Main Content Area: พื้นที่สำหรับแสดงเนื้อหาหลักของหน้าที่เลือก - * 5.2. หน้า Landing Page: เป็นหน้าแรกที่แสดงข้อมูลบางส่วนของโครงการสำหรับผู้ใช้ที่ยังไม่ได้ล็อกอิน - * 5.3. หน้า Dashboard: เป็นหน้าแรกหลังจากล็อกอิน ประกอบด้วย: - - การ์ดสรุปภาพรวม (KPI Cards): แสดงข้อมูลสรุปที่สำคัญขององค์กร เช่น จำนวนเอกสาร, งานที่เกินกำหนด - - ตาราง "งานของฉัน" (My Tasks Table): แสดงรายการงานทั้งหมดจาก Circulation ที่ผู้ใช้ต้องดำเนินการ - * 5.4. การติดตามสถานะ: องค์กรสามารถติดตามสถานะเอกสารทั้งของตนเอง (Originator) และสถานะเอกสารที่ส่งมาถึงตนเอง (Recipient) - * 5.5. การจัดการข้อมูลส่วนตัว (Profile Page): ผู้ใช้สามารถจัดการข้อมูลส่วนตัวและเปลี่ยนรหัสผ่านของตนเองได้ - * 5.6. การจัดการเอกสารทางเทคนิค (Technical Documents & Workflow): ผู้ใช้สามารถดู Technical Document ในรูปแบบ Workflow ทั้งหมดได้ในหน้าเดียว, ขั้นตอนที่ยังไม่ถึงหรือผ่านไปแล้วจะเป็นรูปแบบ diable, สามารถดำเนินการได้เฉพาะในขั้นตอนที่ได้รับมอบหมายงาน (active) เช่น ตรวจสอบแล้ว เพื่อไปยังขั้นตอนต่อไป, สิทธิ์ admin ขึ้นไป สามรถกด ไปยังขั้นตอนต่อไป ได้ทุกขั้นตอน, การย้อนกลับ ไปขั้นตอนก่อนหน้า สามารถทำได้โดย สิทธิ์ admin ขึ้นไป +- 5.1. Layout หลัก: หน้าเว็บใช้รูปแบบ App Shell ที่ประกอบด้วย: + - Navbar (ส่วนบน): แสดงชื่อระบบ, เมนูผู้ใช้ (Profile), เมนูสำหรับ Admin/Superadmin (จัดการผู้ใช้, จัดการสิทธิ์), และปุ่ม Login/Logout + - Sidebar (ด้านข้าง): เป็นเมนูหลักสำหรับเข้าถึงส่วนที่เกี่ยวกับเอกสารทั้งหมด เช่น Dashboard, Correspondences, RFA, Drawings + - Main Content Area: พื้นที่สำหรับแสดงเนื้อหาหลักของหน้าที่เลือก +- 5.2. หน้า Landing Page: เป็นหน้าแรกที่แสดงข้อมูลบางส่วนของโครงการสำหรับผู้ใช้ที่ยังไม่ได้ล็อกอิน +- 5.3. หน้า Dashboard: เป็นหน้าแรกหลังจากล็อกอิน ประกอบด้วย: + - การ์ดสรุปภาพรวม (KPI Cards): แสดงข้อมูลสรุปที่สำคัญขององค์กร เช่น จำนวนเอกสาร, งานที่เกินกำหนด + - ตาราง "งานของฉัน" (My Tasks Table): แสดงรายการงานทั้งหมดจาก Circulation ที่ผู้ใช้ต้องดำเนินการ +- 5.4. การติดตามสถานะ: องค์กรสามารถติดตามสถานะเอกสารทั้งของตนเอง (Originator) และสถานะเอกสารที่ส่งมาถึงตนเอง (Recipient) +- 5.5. การจัดการข้อมูลส่วนตัว (Profile Page): ผู้ใช้สามารถจัดการข้อมูลส่วนตัวและเปลี่ยนรหัสผ่านของตนเองได้ +- 5.6. การจัดการเอกสารทางเทคนิค (Technical Documents & Workflow): ผู้ใช้สามารถดู Technical Document ในรูปแบบ Workflow ทั้งหมดได้ในหน้าเดียว, ขั้นตอนที่ยังไม่ถึงหรือผ่านไปแล้วจะเป็นรูปแบบ diable, สามารถดำเนินการได้เฉพาะในขั้นตอนที่ได้รับมอบหมายงาน (active) เช่น ตรวจสอบแล้ว เพื่อไปยังขั้นตอนต่อไป, สิทธิ์ admin ขึ้นไป สามรถกด ไปยังขั้นตอนต่อไป ได้ทุกขั้นตอน, การย้อนกลับ ไปขั้นตอนก่อนหน้า สามารถทำได้โดย สิทธิ์ admin ขึ้นไป ## 6\. ข้อกำหนดที่ไม่ใช่ฟังก์ชันการทำงาน (Non-Functional Requirements) - * 6.1. การบันทึกการกระทำ (Audit Log): ทุกการกระทำที่สำคัญของผู้ใช้ (สร้าง, แก้ไข, ลบ, ส่ง) จะถูกบันทึกไว้ใน `audit_logs` เพื่อการตรวจสอบย้อนหลัง - * 6.2. การค้นหา (Search): ระบบต้องมีฟังก์ชันการค้นหาขั้นสูง ที่สามารถค้นหาเอกสารจากหลายเงื่อนไขพร้อมกันได้ เช่น ค้นหาจากชื่อเรื่อง, ประเภท, วันที่, และ Tag - * 6.3. การทำรายงาน (Reporting): สามารถจัดทำรายงานสรุปแยกประเภทของ Correspondence ประจำวัน, สัปดาห์, เดือน, และปีได้ - * 6.4. ประสิทธิภาพ (Performance): มีการใช้ Caching กับข้อมูลที่เรียกใช้บ่อย และใช้ Pagination ในตารางข้อมูลเพื่อจัดการข้อมูลจำนวนมาก - * 6.5. ความปลอดภัย (Security): - - มีระบบ Rate Limiting เพื่อป้องกันการโจมตีแบบ Brute-force - - การจัดการ Secret (เช่น รหัสผ่าน DB, JWT Secret) จะต้องทำผ่าน Environment Variable ของ Docker เพื่อความปลอดภัยสูงสุด \ No newline at end of file +- 6.1. การบันทึกการกระทำ (Audit Log): ทุกการกระทำที่สำคัญของผู้ใช้ (สร้าง, แก้ไข, ลบ, ส่ง) จะถูกบันทึกไว้ใน `audit_logs` เพื่อการตรวจสอบย้อนหลัง +- 6.2. การค้นหา (Search): ระบบต้องมีฟังก์ชันการค้นหาขั้นสูง ที่สามารถค้นหาเอกสารจากหลายเงื่อนไขพร้อมกันได้ เช่น ค้นหาจากชื่อเรื่อง, ประเภท, วันที่, และ Tag +- 6.3. การทำรายงาน (Reporting): สามารถจัดทำรายงานสรุปแยกประเภทของ Correspondence ประจำวัน, สัปดาห์, เดือน, และปีได้ +- 6.4. ประสิทธิภาพ (Performance): มีการใช้ Caching กับข้อมูลที่เรียกใช้บ่อย และใช้ Pagination ในตารางข้อมูลเพื่อจัดการข้อมูลจำนวนมาก +- 6.5. ความปลอดภัย (Security): + - มีระบบ Rate Limiting เพื่อป้องกันการโจมตีแบบ Brute-force + - การจัดการ Secret (เช่น รหัสผ่าน DB, JWT Secret) จะต้องทำผ่าน Environment Variable ของ Docker เพื่อความปลอดภัยสูงสุด diff --git a/specs/99-archives/docs/Markdown/LCBP3-DMS — Task Breakdown สำหรับ Phase 2A–2C (v1.4.2).md b/specs/99-archives/docs/Markdown/LCBP3-DMS — Task Breakdown สำหรับ Phase 2A–2C (v1.4.2).md index 1171414..ab0814d 100644 --- a/specs/99-archives/docs/Markdown/LCBP3-DMS — Task Breakdown สำหรับ Phase 2A–2C (v1.4.2).md +++ b/specs/99-archives/docs/Markdown/LCBP3-DMS — Task Breakdown สำหรับ Phase 2A–2C (v1.4.2).md @@ -4,12 +4,12 @@ โครงสร้างประกอบด้วย: -* Objectives -* Deliverables -* Task Breakdown (ละเอียดเป็นลำดับงาน) -* Developer Checklist -* Test Coverage -* Dependencies & Risks +- Objectives +- Deliverables +- Task Breakdown (ละเอียดเป็นลำดับงาน) +- Developer Checklist +- Test Coverage +- Dependencies & Risks --- @@ -19,73 +19,73 @@ ## ✔ Deliverables -* Global ValidationPipe -* Sanitization Layer -* Rate Limit Rules (anonymous/authenticated) -* Security Headers (Helmet) -* XSS / SQL Injection safeguards -* Security Tests +- Global ValidationPipe +- Sanitization Layer +- Rate Limit Rules (anonymous/authenticated) +- Security Headers (Helmet) +- XSS / SQL Injection safeguards +- Security Tests ## 🛠 Task Breakdown ### 2A.1 Validation Pipeline -* ตั้งค่า Global ValidationPipe -* เปิด whitelist, forbidNonWhitelisted -* เพิ่ม custom exception mapping → ErrorModel -* เชื่อม RequestIdInterceptor +- ตั้งค่า Global ValidationPipe +- เปิด whitelist, forbidNonWhitelisted +- เพิ่ม custom exception mapping → ErrorModel +- เชื่อม RequestIdInterceptor ### 2A.2 Input Sanitization Layer -* ติดตั้ง sanitize-html หรือ DOMPurify server-side -* เพิ่ม sanitization middleware สำหรับ: +- ติดตั้ง sanitize-html หรือ DOMPurify server-side +- เพิ่ม sanitization middleware สำหรับ: + - query params + - body JSON + - form inputs - * query params - * body JSON - * form inputs -* เพิ่ม unit test: sanitized input → safe output +- เพิ่ม unit test: sanitized input → safe output ### 2A.3 Security Headers (Helmet) -* เปิดใช้งาน Helmet ทั้งระบบ -* ปรับ policy: `contentSecurityPolicy`, `xssFilter`, `noSniff` -* เพิ่ม config per environment +- เปิดใช้งาน Helmet ทั้งระบบ +- ปรับ policy: `contentSecurityPolicy`, `xssFilter`, `noSniff` +- เพิ่ม config per environment ### 2A.4 Rate Limiting -* Rate limit แบบ 2 ชั้น: +- Rate limit แบบ 2 ชั้น: + - anonymous (เช่น 30 req/min) + - authenticated (100 req/min) - * anonymous (เช่น 30 req/min) - * authenticated (100 req/min) -* สร้าง Redis key pattern: `ratelimit:{ip}` -* สร้าง RateLimitGuard + decorator -* ทดสอบ burst traffic (locust หรือ autocannon) +- สร้าง Redis key pattern: `ratelimit:{ip}` +- สร้าง RateLimitGuard + decorator +- ทดสอบ burst traffic (locust หรือ autocannon) ### 2A.5 SQL Injection / XSS Protection -* เปิด TypeORM parameterized queries -* Sanitizer ตรวจจับ script tags -* เขียน test ที่ inject payload จำลอง +- เปิด TypeORM parameterized queries +- Sanitizer ตรวจจับ script tags +- เขียน test ที่ inject payload จำลอง ### 2A.6 Logging + Error Model Integration -* ผูก SecurityException → Error Model -* เพิ่ม request_id logging +- ผูก SecurityException → Error Model +- เพิ่ม request_id logging ## ✔ Developer Checklist (Phase 2A) -* [ ] ทุก controller มี ValidationPipe ครอบ -* [ ] Sanitization ทำงานในทุก input source -* [ ] Error ทั้งหมดออกตาม Error Model กลาง -* [ ] RateLimitGuard ทำงานผ่าน Redis -* [ ] มี security test อย่างน้อย 10 ชุด +- [ ] ทุก controller มี ValidationPipe ครอบ +- [ ] Sanitization ทำงานในทุก input source +- [ ] Error ทั้งหมดออกตาม Error Model กลาง +- [ ] RateLimitGuard ทำงานผ่าน Redis +- [ ] มี security test อย่างน้อย 10 ชุด ## ✔ Test Coverage (Phase 2A) -* Input injection tests -* Rate limit tests -* Validation rejects undefined fields -* ErrorModel mapping +- Input injection tests +- Rate limit tests +- Validation rejects undefined fields +- ErrorModel mapping --- @@ -95,60 +95,60 @@ ## ✔ Deliverables -* Schema Registry -* Schema Versioning -* Schema Validation Service -* Compatibility Rules -* Schema Migration Tests +- Schema Registry +- Schema Versioning +- Schema Validation Service +- Compatibility Rules +- Schema Migration Tests ## 🛠 Task Breakdown ### 2B.1 Schema Registry -* Entity: `json_schemas`, `json_schema_versions` -* Endpoint: `POST /json-schemas` -* ฟิลด์สำคัญ: schema_id, version, schema_json -* สร้าง SchemaStore class +- Entity: `json_schemas`, `json_schema_versions` +- Endpoint: `POST /json-schemas` +- ฟิลด์สำคัญ: schema_id, version, schema_json +- สร้าง SchemaStore class ### 2B.2 Schema Versioning -* Version rule: semantic versioning (major.minor.patch) -* Migration notes per version -* นโยบาย: Breaking change → major bump -* API: `GET /json-schemas/:id?version=` +- Version rule: semantic versioning (major.minor.patch) +- Migration notes per version +- นโยบาย: Breaking change → major bump +- API: `GET /json-schemas/:id?version=` ### 2B.3 Schema Validation Service -* ใช้ AJV หรือ Fastest-Validator -* preload schema เมื่อ boot server -* mapping validation error → Error Model -* เพิ่ม test: invalid schema / missing fields / wrong types +- ใช้ AJV หรือ Fastest-Validator +- preload schema เมื่อ boot server +- mapping validation error → Error Model +- เพิ่ม test: invalid schema / missing fields / wrong types ### 2B.4 Compatibility Rules -* ตรวจสอบ backward compatibility: +- ตรวจสอบ backward compatibility: + - field removal → major version + - enum shrink → major version - * field removal → major version - * enum shrink → major version -* เพิ่ม script ตรวจ schema diff +- เพิ่ม script ตรวจ schema diff ### 2B.5 Schema Migration Tests -* ทดสอบ schema upgrade (v1 → v2) -* ทดสอบ payload ที่ใช้ version เก่า +- ทดสอบ schema upgrade (v1 → v2) +- ทดสอบ payload ที่ใช้ version เก่า ## ✔ Developer Checklist (Phase 2B) -* [ ] ทุก JSON field อ้างอิง schema version -* [ ] ทุก schema ผ่าน validation -* [ ] Schema diff pass -* [ ] Schema test ครอบครบทุก field +- [ ] ทุก JSON field อ้างอิง schema version +- [ ] ทุก schema ผ่าน validation +- [ ] Schema diff pass +- [ ] Schema test ครอบครบทุก field ## ✔ Test Coverage (Phase 2B) -* Schema version switch tests -* Incompatible payload rejection -* Schema registry CRUD +- Schema version switch tests +- Incompatible payload rejection +- Schema registry CRUD --- @@ -158,76 +158,76 @@ ## ✔ Deliverables -* JSON size validator -* JSON sanitizer -* JSON compressor (gzip/deflate) -* Sensitive fields encryption -* JSON transformation layer +- JSON size validator +- JSON sanitizer +- JSON compressor (gzip/deflate) +- Sensitive fields encryption +- JSON transformation layer ## 🛠 Task Breakdown ### 2C.1 JSON Size Controls -* ตั้ง global limit (เช่น 2MB ต่อฟิลด์) -* เพิ่ม JSONSizeGuard -* เขียน test: oversize JSON → error_code: `JSON.TOO_LARGE` +- ตั้ง global limit (เช่น 2MB ต่อฟิลด์) +- เพิ่ม JSONSizeGuard +- เขียน test: oversize JSON → error_code: `JSON.TOO_LARGE` ### 2C.2 JSON Sanitization (ลึกกว่า Phase 2A) -* ล้าง nested fields -* ล้าง `

Chat history

+ + + + + + + จัดการตำแหน่ง Node n8n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+

+ Chat history +

+ +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ + + + + + + + + + + + - + - \ No newline at end of file + + + + + diff --git a/จัดการตำแหน่ง Node n8n_files/ansi-1f6vhsjh.css b/จัดการตำแหน่ง Node n8n_files/ansi-1f6vhsjh.css index 19e7a24..158b0c6 100644 --- a/จัดการตำแหน่ง Node n8n_files/ansi-1f6vhsjh.css +++ b/จัดการตำแหน่ง Node n8n_files/ansi-1f6vhsjh.css @@ -1 +1,72 @@ -.ansi-black-fg{color:#000}.ansi-black-bg{background-color:#000}.ansi-red-fg{color:#f66}.ansi-red-bg{background-color:#f66}.ansi-green-fg{color:#94f494}.ansi-green-bg{background-color:#94f494}.ansi-yellow-fg{color:#f4f47b}.ansi-yellow-bg{background-color:#f4f47b}.ansi-blue-fg{color:#9e9eff}.ansi-blue-bg{background-color:#9e9eff}.ansi-magenta-fg{color:#db6bdb}.ansi-magenta-bg{background-color:#db6bdb}.ansi-cyan-fg{color:#81eeee}.ansi-cyan-bg{background-color:#81eeee}.ansi-white-fg{color:#d6d6d6}.ansi-white-bg{background-color:#d6d6d6}.ansi-bright-black-fg{color:#6e6e6e}.ansi-bright-red-fg{color:#ffa8a8}.ansi-bright-green-fg{color:#0f0}.ansi-bright-yellow-fg{color:#ffffa8}.ansi-bright-blue-fg{color:#9494ff}.ansi-bright-magenta-fg{color:#ffb3ff}.ansi-bright-cyan-fg{color:#adffff}.ansi-bright-white-fg{color:#fff} +.ansi-black-fg { + color: #000; +} +.ansi-black-bg { + background-color: #000; +} +.ansi-red-fg { + color: #f66; +} +.ansi-red-bg { + background-color: #f66; +} +.ansi-green-fg { + color: #94f494; +} +.ansi-green-bg { + background-color: #94f494; +} +.ansi-yellow-fg { + color: #f4f47b; +} +.ansi-yellow-bg { + background-color: #f4f47b; +} +.ansi-blue-fg { + color: #9e9eff; +} +.ansi-blue-bg { + background-color: #9e9eff; +} +.ansi-magenta-fg { + color: #db6bdb; +} +.ansi-magenta-bg { + background-color: #db6bdb; +} +.ansi-cyan-fg { + color: #81eeee; +} +.ansi-cyan-bg { + background-color: #81eeee; +} +.ansi-white-fg { + color: #d6d6d6; +} +.ansi-white-bg { + background-color: #d6d6d6; +} +.ansi-bright-black-fg { + color: #6e6e6e; +} +.ansi-bright-red-fg { + color: #ffa8a8; +} +.ansi-bright-green-fg { + color: #0f0; +} +.ansi-bright-yellow-fg { + color: #ffffa8; +} +.ansi-bright-blue-fg { + color: #9494ff; +} +.ansi-bright-magenta-fg { + color: #ffb3ff; +} +.ansi-bright-cyan-fg { + color: #adffff; +} +.ansi-bright-white-fg { + color: #fff; +} diff --git a/จัดการตำแหน่ง Node n8n_files/code-block-m2vhenll.css b/จัดการตำแหน่ง Node n8n_files/code-block-m2vhenll.css index c561bc6..2f489a7 100644 --- a/จัดการตำแหน่ง Node n8n_files/code-block-m2vhenll.css +++ b/จัดการตำแหน่ง Node n8n_files/code-block-m2vhenll.css @@ -1 +1,5 @@ -@supports not (overflow:clip){.lxnfua_clipPathFallback{clip-path:inset(-1px round 26px)}} +@supports not (overflow: clip) { + .lxnfua_clipPathFallback { + clip-path: inset(-1px round 26px); + } +} diff --git a/จัดการตำแหน่ง Node n8n_files/code-block-viewer-fzo1sbww.css b/จัดการตำแหน่ง Node n8n_files/code-block-viewer-fzo1sbww.css index e5c0216..ee020db 100644 --- a/จัดการตำแหน่ง Node n8n_files/code-block-viewer-fzo1sbww.css +++ b/จัดการตำแหน่ง Node n8n_files/code-block-viewer-fzo1sbww.css @@ -1 +1,48 @@ -.q9tKkq_viewer{background-color:var(--codemirror-bg,transparent);border:none;border-radius:0;font-size:14px;overflow:hidden}.q9tKkq_viewer .cm-scroller{scroll-behavior:auto;overscroll-behavior-x:contain;align-items:stretch;display:flex;overflow-x:auto}.q9tKkq_viewer .cm-content{white-space:pre;min-width:-webkit-max-content;min-width:max-content;padding:0 calc(var(--spacing) * 4) calc(var(--spacing) * 3);flex:1;line-height:20px;font-family:var(--font-mono)!important}@media (min-width:768px){.q9tKkq_viewer .cm-content{padding:0 calc(var(--spacing) * 5) calc(var(--spacing) * 3)}}.q9tKkq_viewer .cm-content{color:var(--text-primary)}.q9tKkq_viewer .cm-content ::selection{background-color:var(--theme-user-selection-bg)}.q9tKkq_viewer.q9tKkq_wrapLines .cm-scroller{overflow-x:hidden}.q9tKkq_viewer.q9tKkq_wrapLines .cm-content{white-space:pre-wrap;overflow-wrap:anywhere;min-width:0}.fullscreen .q9tKkq_viewer .cm-content{padding-top:calc(var(--spacing) * 3)}.q9tKkq_readonly{cursor:text} +.q9tKkq_viewer { + background-color: var(--codemirror-bg, transparent); + border: none; + border-radius: 0; + font-size: 14px; + overflow: hidden; +} +.q9tKkq_viewer .cm-scroller { + scroll-behavior: auto; + overscroll-behavior-x: contain; + align-items: stretch; + display: flex; + overflow-x: auto; +} +.q9tKkq_viewer .cm-content { + white-space: pre; + min-width: -webkit-max-content; + min-width: max-content; + padding: 0 calc(var(--spacing) * 4) calc(var(--spacing) * 3); + flex: 1; + line-height: 20px; + font-family: var(--font-mono) !important; +} +@media (min-width: 768px) { + .q9tKkq_viewer .cm-content { + padding: 0 calc(var(--spacing) * 5) calc(var(--spacing) * 3); + } +} +.q9tKkq_viewer .cm-content { + color: var(--text-primary); +} +.q9tKkq_viewer .cm-content ::selection { + background-color: var(--theme-user-selection-bg); +} +.q9tKkq_viewer.q9tKkq_wrapLines .cm-scroller { + overflow-x: hidden; +} +.q9tKkq_viewer.q9tKkq_wrapLines .cm-content { + white-space: pre-wrap; + overflow-wrap: anywhere; + min-width: 0; +} +.fullscreen .q9tKkq_viewer .cm-content { + padding-top: calc(var(--spacing) * 3); +} +.q9tKkq_readonly { + cursor: text; +} diff --git a/จัดการตำแหน่ง Node n8n_files/conversation-small-tteqv2xp.css b/จัดการตำแหน่ง Node n8n_files/conversation-small-tteqv2xp.css index 27ff5bf..b2a1b17 100644 --- a/จัดการตำแหน่ง Node n8n_files/conversation-small-tteqv2xp.css +++ b/จัดการตำแหน่ง Node n8n_files/conversation-small-tteqv2xp.css @@ -1 +1,1912 @@ -.OeMrba_wrapper{cursor:pointer;-webkit-user-select:none;user-select:none;border-radius:8px;width:44px;height:44px;display:inline-flex}.OeMrba_wrapper>input[type=checkbox]{display:none}.OeMrba_wrapper{color:var(--icon-secondary)}@media (hover:hover) and (pointer:fine){.OeMrba_wrapper:hover:not(.OeMrba_disabled){--hover-background:var(--main-surface-secondary)}}.OeMrba_label{background-color:var(--hover-background);color:var(--text-secondary);border-radius:8px;flex:1;justify-content:center;align-items:center;transition:background-color .1s linear;display:flex}@keyframes sR_mOW_slide-up{0%{opacity:0;translate:0 20vw}}@keyframes sR_mOW_slide-down{to{opacity:0;translate:0 20vw}}@keyframes sR_mOW_pinnedOldFastFade{0%{opacity:1}20%{opacity:0}to{opacity:0}}@view-transition{}.sR_mOW_page-to-page-transition{view-transition-name:none}.sR_mOW_page-to-page-transition body{view-transition-name:sR_mOW_page}@media (prefers-reduced-motion:reduce){.sR_mOW_page-to-page-transition::view-transition{display:none}}.sR_mOW_page-to-page-transition::view-transition-old(sR_mOW_header),.sR_mOW_page-to-page-transition::view-transition-old(sR_mOW_sidebar){display:none}.sR_mOW_page-to-page-transition::view-transition-new(sR_mOW_header),.sR_mOW_page-to-page-transition::view-transition-new(sR_mOW_sidebar){animation:none}.sR_mOW_page-to-page-transition::view-transition-image-pair(sR_mOW_active-image),.sR_mOW_page-to-page-transition::view-transition-old(sR_mOW_active-image),.sR_mOW_page-to-page-transition::view-transition-new(sR_mOW_active-image){height:100%}.sR_mOW_page-to-page-transition::view-transition-image-pair(sR_mOW_page-title),.sR_mOW_page-to-page-transition::view-transition-old(sR_mOW_page-title),.sR_mOW_page-to-page-transition::view-transition-new(sR_mOW_page-title){height:100%}.sR_mOW_page-to-page-transition::view-transition-image-pair(sR_mOW_acive-image),.sR_mOW_page-to-page-transition::view-transition-old(sR_mOW_acive-image),.sR_mOW_page-to-page-transition::view-transition-new(sR_mOW_acive-image){height:100%}.sR_mOW_page-to-page-transition::view-transition-group(*),.sR_mOW_page-to-page-transition::view-transition-old(*),.sR_mOW_page-to-page-transition::view-transition-new(*){animation-duration:.4s;animation-timing-function:var(--spring-fast)}.sR_mOW_page-to-page-transition.from-library.to-conversation,.sR_mOW_page-to-page-transition.from-lightbox.to-conversation{--vt-active-image:active-image}:is(.sR_mOW_page-to-page-transition.to-home,.sR_mOW_page-to-page-transition.from-landing-page)::view-transition-new(sR_mOW_composer){animation:none}.sR_mOW_page-to-page-transition.to-landing-page,.sR_mOW_page-to-page-transition.from-landing-page{--vt-page-header:header;--vt-splash-screen-headline:page-title;--vt-tool-page-title:page-title;--vt-composer:composer;--sidebar-slideover:sidebar}.sR_mOW_page-to-page-transition.to-landing-page::view-transition-new(sR_mOW_page){animation:sR_mOW_slide-up .4s var(--spring-fast)}.sR_mOW_composer-slide{--vt-composer:composer}.sR_mOW_composer-slide::view-transition-old(sR_mOW_composer),.sR_mOW_composer-slide::view-transition-group(sR_mOW_composer){animation-duration:.5s;animation-timing-function:var(--spring-fast)}.sR_mOW_grid-item{--vt-grid-item:grid-item}.sR_mOW_grid-item::view-transition-old(sR_mOW_grid-item),.sR_mOW_grid-item::view-transition-new(sR_mOW_grid-item){object-fit:fill;transform-origin:50%;width:100%;height:100%;animation-duration:.5s;animation-timing-function:var(--spring-fast)}.sR_mOW_pinned-widget{--vt-composer:composer;--vt-disclaimer:disclaimer}.sR_mOW_pinned-widget::view-transition,.sR_mOW_pinned-widget::view-transition-group(sR_mOW_pinned-kanzi-widget),.sR_mOW_pinned-widget::view-transition-image-pair(sR_mOW_pinned-kanzi-widget),.sR_mOW_pinned-widget::view-transition-old(sR_mOW_pinned-kanzi-widget),.sR_mOW_pinned-widget::view-transition-new(sR_mOW_pinned-kanzi-widget){background:0 0}.sR_mOW_pinned-widget::view-transition-old(sR_mOW_pinned-kanzi-widget){animation-name:sR_mOW_pinnedOldFastFade;animation-duration:.1s;animation-timing-function:var(--spring-fast);object-fit:fill;transform-origin:50%;width:100%;height:100%}.sR_mOW_pinned-widget::view-transition-new(sR_mOW_pinned-kanzi-widget){object-fit:fill;transform-origin:50%;width:100%;height:100%;animation-duration:.2s;animation-timing-function:var(--spring-fast)}.sR_mOW_pinned-widget::view-transition-old(sR_mOW_composer),.sR_mOW_pinned-widget::view-transition-group(sR_mOW_composer){z-index:999}.sR_mOW_pinned-widget::view-transition-old(sR_mOW_disclaimer),.sR_mOW_pinned-widget::view-transition-group(sR_mOW_disclaimer){z-index:999}.sR_mOW_pinned-widget.sR_mOW_becoming-inline::view-transition-old(sR_mOW_pinned-kanzi-widget){animation-duration:.25s;animation-delay:.1s}.sR_mOW_pinned-widget.sR_mOW_becoming-inline::view-transition-new(sR_mOW_pinned-kanzi-widget){animation-duration:50ms;animation-delay:.1s}.sR_mOW_becoming-inline{--vt-becoming-inline:1}.sR_mOW_pinned-widget.sR_mOW_becoming-pinned::view-transition-old(sR_mOW_pinned-kanzi-widget){animation-duration:.35s;animation-delay:0s;animation-timing-function:var(--spring-fast)}.sR_mOW_pinned-widget.sR_mOW_becoming-pinned::view-transition-new(sR_mOW_pinned-kanzi-widget){animation-duration:.1s;animation-delay:0s;animation-timing-function:var(--spring-fast)}.sR_mOW_becoming-pinned{--vt-becoming-pinned:1}.sR_mOW_fullscreen-widget-composer-transition{--vt-composer:composer}.sR_mOW_fullscreen-widget-composer-transition::view-transition-old(sR_mOW_composer),.sR_mOW_fullscreen-widget-composer-transition::view-transition-new(sR_mOW_composer){object-fit:fill;transform-origin:50%;width:100%;height:100%;animation-duration:.25s;animation-timing-function:var(--spring-fast)}.sR_mOW_places-overlay-transition{--vt-composer:places-overlay-composer}.sR_mOW_places-overlay-transition::view-transition-group(sR_mOW_business-list-container),.sR_mOW_places-overlay-transition::view-transition-old(sR_mOW_business-list-container),.sR_mOW_places-overlay-transition::view-transition-new(sR_mOW_business-list-container){opacity:1;animation:none}.sR_mOW_places-overlay-transition::view-transition-group(sR_mOW_map-with-entities),.sR_mOW_places-overlay-transition::view-transition-old(sR_mOW_map-with-entities),.sR_mOW_places-overlay-transition::view-transition-new(sR_mOW_map-with-entities){z-index:0}.sR_mOW_places-overlay-transition::view-transition-group(sR_mOW_places-overlay-composer),.sR_mOW_places-overlay-transition::view-transition-old(sR_mOW_places-overlay-composer),.sR_mOW_places-overlay-transition::view-transition-new(sR_mOW_places-overlay-composer){z-index:999;animation-duration:.22s;animation-timing-function:var(--spring-fast);object-fit:fill;transform-origin:bottom;width:100%;height:100%}.sR_mOW_fullscreen-popover-thread-opening-transition,.sR_mOW_fullscreen-popover-thread-closing-transition{--vt-composer:composer}:is(.sR_mOW_fullscreen-popover-thread-opening-transition,.sR_mOW_fullscreen-popover-thread-closing-transition)::view-transition-old(sR_mOW_composer),:is(.sR_mOW_fullscreen-popover-thread-opening-transition,.sR_mOW_fullscreen-popover-thread-closing-transition)::view-transition-group(sR_mOW_composer){z-index:999}.sR_mOW_fullscreen-popover-thread-opening-transition::view-transition-new(sR_mOW_fullscreen-popover-thread){z-index:0;transform-origin:bottom;animation:sR_mOW_popover-thread-enter .3s var(--spring-fast) both}.sR_mOW_fullscreen-popover-thread-closing-transition::view-transition-old(sR_mOW_fullscreen-popover-thread){z-index:0;transform-origin:bottom;animation:sR_mOW_popover-thread-exit .3s var(--spring-fast) both}@keyframes sR_mOW_popover-thread-enter{0%{opacity:0;transform:scale(.98)}to{opacity:1;transform:scale(1)}}@keyframes sR_mOW_popover-thread-exit{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.98)}}.mapboxgl-map{-webkit-tap-highlight-color:transparent;font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;position:relative;overflow:hidden}.mapboxgl-canvas{position:absolute;top:0;left:0}.mapboxgl-map:-webkit-full-screen{width:100%;height:100%}.mapboxgl-canary{background-color:salmon}.mapboxgl-canvas-container.mapboxgl-interactive,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass{cursor:grab;-webkit-user-select:none;user-select:none}.mapboxgl-canvas-container.mapboxgl-interactive.mapboxgl-track-pointer{cursor:pointer}.mapboxgl-canvas-container.mapboxgl-interactive:active,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass:active{cursor:grabbing}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate .mapboxgl-canvas{touch-action:pan-x pan-y}.mapboxgl-canvas-container.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:pinch-zoom}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:none}.mapboxgl-ctrl-bottom,.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,.mapboxgl-ctrl-left,.mapboxgl-ctrl-right,.mapboxgl-ctrl-top,.mapboxgl-ctrl-top-left,.mapboxgl-ctrl-top-right{pointer-events:none;z-index:2;position:absolute}.mapboxgl-ctrl-top-left{top:0;left:0}.mapboxgl-ctrl-top{top:0;left:50%;transform:translate(-50%)}.mapboxgl-ctrl-top-right{top:0;right:0}.mapboxgl-ctrl-right{top:50%;right:0;transform:translateY(-50%)}.mapboxgl-ctrl-bottom-right{bottom:0;right:0}.mapboxgl-ctrl-bottom{bottom:0;left:50%;transform:translate(-50%)}.mapboxgl-ctrl-bottom-left{bottom:0;left:0}.mapboxgl-ctrl-left{top:50%;left:0;transform:translateY(-50%)}.mapboxgl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.mapboxgl-ctrl-top-left .mapboxgl-ctrl{float:left;margin:10px 0 0 10px}.mapboxgl-ctrl-top .mapboxgl-ctrl{float:left;margin:10px 0}.mapboxgl-ctrl-top-right .mapboxgl-ctrl{float:right;margin:10px 10px 0 0}.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl,.mapboxgl-ctrl-right .mapboxgl-ctrl{float:right;margin:0 10px 10px 0}.mapboxgl-ctrl-bottom .mapboxgl-ctrl{float:left;margin:10px 0}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl,.mapboxgl-ctrl-left .mapboxgl-ctrl{float:left;margin:0 0 10px 10px}.mapboxgl-ctrl-group{background:#fff;border-radius:4px}.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px #0000001a}@media (-ms-high-contrast:active){.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px buttontext}}.mapboxgl-ctrl-group button{background-color:initial;box-sizing:border-box;cursor:pointer;border:0;outline:none;width:29px;height:29px;padding:0;display:block;overflow:hidden}.mapboxgl-ctrl-group button+button{border-top:1px solid #ddd}.mapboxgl-ctrl button .mapboxgl-ctrl-icon{background-position:50%;background-repeat:no-repeat;width:100%;height:100%;display:block}@media (-ms-high-contrast:active){.mapboxgl-ctrl-icon{background-color:initial}.mapboxgl-ctrl-group button+button{border-top:1px solid buttontext}}.mapboxgl-ctrl-attrib-button:focus,.mapboxgl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl button:disabled{cursor:not-allowed}.mapboxgl-ctrl button:disabled .mapboxgl-ctrl-icon{opacity:.25}.mapboxgl-ctrl-group button:first-child{border-radius:4px 4px 0 0}.mapboxgl-ctrl-group button:last-child{border-radius:0 0 4px 4px}.mapboxgl-ctrl-group button:only-child{border-radius:inherit}.mapboxgl-ctrl button:not(:disabled):hover{background-color:#0000000d}.mapboxgl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23999'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-buildings-toggle .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='none' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath d='M3.3335 11.6666C3.3335 12.5871 4.07969 13.3333 5.00016 13.3333C5.92064 13.3333 6.66683 12.5871 6.66683 11.6666L6.66683 6.66659L11.6668 6.66659C12.5873 6.66659 13.3335 5.92039 13.3335 4.99992C13.3335 4.07944 12.5873 3.33325 11.6668 3.33325H3.3335V11.6666Z' fill='currentColor'/%3E%3Cpath d='M26.6668 11.6666C26.6668 12.5871 25.9206 13.3333 25.0002 13.3333C24.0797 13.3333 23.3335 12.5871 23.3335 11.6666L23.3335 6.66659L18.3335 6.66659C17.413 6.66659 16.6668 5.92039 16.6668 4.99992C16.6668 4.07944 17.413 3.33325 18.3335 3.33325H26.6668L26.6668 11.6666Z' fill='currentColor'/%3E%3Cpath d='M13.3335 24.9999C13.3335 25.9204 12.5873 26.6666 11.6668 26.6666H3.3335V18.3333C3.3335 17.4128 4.07969 16.6666 5.00016 16.6666C5.92064 16.6666 6.66683 17.4128 6.66683 18.3333V23.3333H11.6668C12.5873 23.3333 13.3335 24.0794 13.3335 24.9999Z' fill='currentColor'/%3E%3Cpath d='M18.3335 26.6666C17.413 26.6666 16.6668 25.9204 16.6668 24.9999C16.6668 24.0794 17.413 23.3333 18.3335 23.3333H23.3335V18.3333C23.3335 17.4128 24.0797 16.6666 25.0002 16.6666C25.9206 16.6666 26.6668 17.4128 26.6668 18.3333V26.6666H18.3335Z' fill='currentColor'/%3E%3C/svg%3E");background-size:26px 26px}.mapboxgl-ctrl button.mapboxgl-ctrl-buildings-toggle.mapboxgl-ctrl-level-button-selected .mapboxgl-ctrl-icon{filter:invert()brightness()}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23aaa'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-waiting .mapboxgl-ctrl-icon{animation:2s linear infinite mapboxgl-spin}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23999'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23666'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}}@keyframes mapboxgl-spin{0%{transform:rotate(0)}to{transform:rotate(1turn)}}a.mapboxgl-ctrl-logo{cursor:pointer;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='0.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='0.9' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;width:88px;height:23px;margin:0 0 -4px -4px;display:block;overflow:hidden}a.mapboxgl-ctrl-logo.mapboxgl-compact{width:23px}@media (-ms-high-contrast:active){a.mapboxgl-ctrl-logo{background-color:initial;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23fff' stroke-width='3' fill='%23fff'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23000'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}.mapboxgl-ctrl.mapboxgl-ctrl-attrib{background-color:#ffffff80;margin:0;padding:0 5px}@media screen{.mapboxgl-ctrl-attrib.mapboxgl-compact{box-sizing:initial;background-color:#fff;border-radius:12px;min-height:20px;margin:10px;padding:2px 24px 2px 0;position:relative}.mapboxgl-ctrl-attrib.mapboxgl-compact-show{visibility:visible;padding:2px 28px 2px 8px}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show,.mapboxgl-ctrl-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show,.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-inner{display:none}.mapboxgl-ctrl-attrib-button{box-sizing:border-box;cursor:pointer;background-color:#ffffff80;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;outline:none;width:24px;height:24px;display:none;position:absolute;top:0;right:0}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-left .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-top-left .mapboxgl-ctrl-attrib-button{left:0}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-inner{display:block}.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button{background-color:#0000000d}.mapboxgl-ctrl-bottom-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;right:0}.mapboxgl-ctrl-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{right:0}.mapboxgl-ctrl-top-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{top:0;right:0}.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{top:0;left:0}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;left:0}.mapboxgl-ctrl-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{left:0}}@media screen and (-ms-high-contrast:active){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' fill='%23fff'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (-ms-high-contrast:black-on-white){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.mapboxgl-ctrl-attrib a{color:#000000bf;text-decoration:none}.mapboxgl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.mapboxgl-ctrl-attrib .mapbox-improve-map{margin-left:2px;font-weight:700}.mapboxgl-attrib-empty{display:none}.mapboxgl-ctrl-scale{box-sizing:border-box;color:#333;white-space:nowrap;background-color:#ffffffbf;border:2px solid #333;border-top:#333;padding:0 5px;font-size:10px}.mapboxgl-popup{pointer-events:none;will-change:transform;display:flex;position:absolute;top:0;left:0}.mapboxgl-popup-anchor-top,.mapboxgl-popup-anchor-top-left,.mapboxgl-popup-anchor-top-right{flex-direction:column}.mapboxgl-popup-anchor-bottom,.mapboxgl-popup-anchor-bottom-left,.mapboxgl-popup-anchor-bottom-right{flex-direction:column-reverse}.mapboxgl-popup-anchor-left{flex-direction:row}.mapboxgl-popup-anchor-right{flex-direction:row-reverse}.mapboxgl-popup-tip{z-index:1;border:10px solid #0000;width:0;height:0}.mapboxgl-popup-anchor-top .mapboxgl-popup-tip{border-top:none;border-bottom-color:#fff;align-self:center}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip{border-top:none;border-bottom-color:#fff;border-left:none;align-self:flex-start}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip{border-top:none;border-bottom-color:#fff;border-right:none;align-self:flex-end}.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip{border-top-color:#fff;border-bottom:none;align-self:center}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip{border-top-color:#fff;border-bottom:none;border-left:none;align-self:flex-start}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip{border-top-color:#fff;border-bottom:none;border-right:none;align-self:flex-end}.mapboxgl-popup-anchor-left .mapboxgl-popup-tip{border-left:none;border-right-color:#fff;align-self:center}.mapboxgl-popup-anchor-right .mapboxgl-popup-tip{border-left-color:#fff;border-right:none;align-self:center}.mapboxgl-popup-close-button{background-color:initial;cursor:pointer;border:0;border-radius:0 3px 0 0;position:absolute;top:0;right:0}.mapboxgl-popup-close-button:hover{background-color:#0000000d}.mapboxgl-popup-content{pointer-events:auto;background:#fff;border-radius:3px;padding:10px 10px 15px;position:relative;box-shadow:0 1px 2px #0000001a}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-content{border-top-left-radius:0}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-content{border-top-right-radius:0}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-content{border-bottom-left-radius:0}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-content{border-bottom-right-radius:0}.mapboxgl-popup-track-pointer{display:none}.mapboxgl-popup-track-pointer *{pointer-events:none;-webkit-user-select:none;user-select:none}.mapboxgl-map:hover .mapboxgl-popup-track-pointer{display:flex}.mapboxgl-map:active .mapboxgl-popup-track-pointer{display:none}.mapboxgl-marker{opacity:1;will-change:transform;transition:opacity .2s;position:absolute;top:0;left:0}.mapboxgl-user-location-dot,.mapboxgl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;width:15px;height:15px}.mapboxgl-user-location-dot:before{content:"";animation:2s infinite mapboxgl-user-location-dot-pulse;position:absolute}.mapboxgl-user-location-dot:after{box-sizing:border-box;content:"";border:2px solid #fff;border-radius:50%;width:19px;height:19px;position:absolute;top:-2px;left:-2px;box-shadow:0 0 3px #00000059}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading{width:0;height:0}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after,.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{content:"";border-bottom:7.5px solid #4aa1eb;position:absolute}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{border-left:7.5px solid #0000;transform:translateY(-28px)skewY(-20deg)}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after{border-right:7.5px solid #0000;transform:translate(7.5px,-28px)skewY(20deg)}@keyframes mapboxgl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.mapboxgl-user-location-dot-stale{background-color:#aaa}.mapboxgl-user-location-dot-stale:after{display:none}.mapboxgl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;width:1px;height:1px}.mapboxgl-crosshair,.mapboxgl-crosshair .mapboxgl-interactive,.mapboxgl-crosshair .mapboxgl-interactive:active{cursor:crosshair}.mapboxgl-boxzoom{opacity:.5;background:#fff;border:2px dotted #202020;width:0;height:0;position:absolute;top:0;left:0}@media print{.mapbox-improve-map{display:none}}.mapboxgl-scroll-zoom-blocker,.mapboxgl-touch-pan-blocker{color:#fff;opacity:0;pointer-events:none;text-align:center;background:#000000b3;justify-content:center;align-items:center;width:100%;height:100%;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;transition:opacity .75s ease-in-out 1s;display:flex;position:absolute;top:0;left:0}.mapboxgl-scroll-zoom-blocker-show,.mapboxgl-touch-pan-blocker-show{opacity:1;transition:opacity .1s ease-in-out}.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page,.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page .mapboxgl-canvas{touch-action:pan-x pan-y}.mapboxgl-ctrl-separator{background-color:#e0e0e0;height:1px}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button{color:#333;width:50px;height:50px;font-size:18px;font-weight:700}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button:first-child{border-top-left-radius:8px;border-top-right-radius:8px}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button:last-child{border-bottom-right-radius:8px;border-bottom-left-radius:8px}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button:hover{background-color:#f5f5f5}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button-selected{color:#fff;background-color:#4a5568}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button-selected:hover{background-color:#2d3748}@font-face{font-family:swiper-icons;src:url("data:application/font-woff;charset=utf-8;base64, d09GRgABAAAAAAZgABAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAAGRAAAABoAAAAci6qHkUdERUYAAAWgAAAAIwAAACQAYABXR1BPUwAABhQAAAAuAAAANuAY7+xHU1VCAAAFxAAAAFAAAABm2fPczU9TLzIAAAHcAAAASgAAAGBP9V5RY21hcAAAAkQAAACIAAABYt6F0cBjdnQgAAACzAAAAAQAAAAEABEBRGdhc3AAAAWYAAAACAAAAAj//wADZ2x5ZgAAAywAAADMAAAD2MHtryVoZWFkAAABbAAAADAAAAA2E2+eoWhoZWEAAAGcAAAAHwAAACQC9gDzaG10eAAAAigAAAAZAAAArgJkABFsb2NhAAAC0AAAAFoAAABaFQAUGG1heHAAAAG8AAAAHwAAACAAcABAbmFtZQAAA/gAAAE5AAACXvFdBwlwb3N0AAAFNAAAAGIAAACE5s74hXjaY2BkYGAAYpf5Hu/j+W2+MnAzMYDAzaX6QjD6/4//Bxj5GA8AuRwMYGkAPywL13jaY2BkYGA88P8Agx4j+/8fQDYfA1AEBWgDAIB2BOoAeNpjYGRgYNBh4GdgYgABEMnIABJzYNADCQAACWgAsQB42mNgYfzCOIGBlYGB0YcxjYGBwR1Kf2WQZGhhYGBiYGVmgAFGBiQQkOaawtDAoMBQxXjg/wEGPcYDDA4wNUA2CCgwsAAAO4EL6gAAeNpj2M0gyAACqxgGNWBkZ2D4/wMA+xkDdgAAAHjaY2BgYGaAYBkGRgYQiAHyGMF8FgYHIM3DwMHABGQrMOgyWDLEM1T9/w8UBfEMgLzE////P/5//f/V/xv+r4eaAAeMbAxwIUYmIMHEgKYAYjUcsDAwsLKxc3BycfPw8jEQA/gZBASFhEVExcQlJKWkZWTl5BUUlZRVVNXUNTQZBgMAAMR+E+gAEQFEAAAAKgAqACoANAA+AEgAUgBcAGYAcAB6AIQAjgCYAKIArAC2AMAAygDUAN4A6ADyAPwBBgEQARoBJAEuATgBQgFMAVYBYAFqAXQBfgGIAZIBnAGmAbIBzgHsAAB42u2NMQ6CUAyGW568x9AneYYgm4MJbhKFaExIOAVX8ApewSt4Bic4AfeAid3VOBixDxfPYEza5O+Xfi04YADggiUIULCuEJK8VhO4bSvpdnktHI5QCYtdi2sl8ZnXaHlqUrNKzdKcT8cjlq+rwZSvIVczNiezsfnP/uznmfPFBNODM2K7MTQ45YEAZqGP81AmGGcF3iPqOop0r1SPTaTbVkfUe4HXj97wYE+yNwWYxwWu4v1ugWHgo3S1XdZEVqWM7ET0cfnLGxWfkgR42o2PvWrDMBSFj/IHLaF0zKjRgdiVMwScNRAoWUoH78Y2icB/yIY09An6AH2Bdu/UB+yxopYshQiEvnvu0dURgDt8QeC8PDw7Fpji3fEA4z/PEJ6YOB5hKh4dj3EvXhxPqH/SKUY3rJ7srZ4FZnh1PMAtPhwP6fl2PMJMPDgeQ4rY8YT6Gzao0eAEA409DuggmTnFnOcSCiEiLMgxCiTI6Cq5DZUd3Qmp10vO0LaLTd2cjN4fOumlc7lUYbSQcZFkutRG7g6JKZKy0RmdLY680CDnEJ+UMkpFFe1RN7nxdVpXrC4aTtnaurOnYercZg2YVmLN/d/gczfEimrE/fs/bOuq29Zmn8tloORaXgZgGa78yO9/cnXm2BpaGvq25Dv9S4E9+5SIc9PqupJKhYFSSl47+Qcr1mYNAAAAeNptw0cKwkAAAMDZJA8Q7OUJvkLsPfZ6zFVERPy8qHh2YER+3i/BP83vIBLLySsoKimrqKqpa2hp6+jq6RsYGhmbmJqZSy0sraxtbO3sHRydnEMU4uR6yx7JJXveP7WrDycAAAAAAAH//wACeNpjYGRgYOABYhkgZgJCZgZNBkYGLQZtIJsFLMYAAAw3ALgAeNolizEKgDAQBCchRbC2sFER0YD6qVQiBCv/H9ezGI6Z5XBAw8CBK/m5iQQVauVbXLnOrMZv2oLdKFa8Pjuru2hJzGabmOSLzNMzvutpB3N42mNgZGBg4GKQYzBhYMxJLMlj4GBgAYow/P/PAJJhLM6sSoWKfWCAAwDAjgbRAAB42mNgYGBkAIIbCZo5IPrmUn0hGA0AO8EFTQAA");font-weight:400;font-style:normal}:root{--swiper-theme-color:#007aff}:host{z-index:1;margin-left:auto;margin-right:auto;display:block;position:relative}.swiper{z-index:1;margin-left:auto;margin-right:auto;padding:0;list-style:none;display:block;position:relative;overflow:hidden}.swiper-vertical>.swiper-wrapper{flex-direction:column}.swiper-wrapper{z-index:1;width:100%;height:100%;transition-property:transform;transition-timing-function:var(--swiper-wrapper-transition-timing-function,initial);box-sizing:content-box;display:flex;position:relative}.swiper-android .swiper-slide,.swiper-ios .swiper-slide,.swiper-wrapper{transform:translate(0)}.swiper-horizontal{touch-action:pan-y}.swiper-vertical{touch-action:pan-x}.swiper-slide{flex-shrink:0;width:100%;height:100%;transition-property:transform;display:block;position:relative}.swiper-slide-invisible-blank{visibility:hidden}.swiper-autoheight,.swiper-autoheight .swiper-slide{height:auto}.swiper-autoheight .swiper-wrapper{align-items:flex-start;transition-property:transform,height}.swiper-backface-hidden .swiper-slide{-webkit-backface-visibility:hidden;backface-visibility:hidden;transform:translateZ(0)}.swiper-3d.swiper-css-mode .swiper-wrapper{perspective:1200px}.swiper-3d .swiper-wrapper{transform-style:preserve-3d}.swiper-3d{perspective:1200px}.swiper-3d .swiper-slide,.swiper-3d .swiper-cube-shadow{transform-style:preserve-3d}.swiper-css-mode>.swiper-wrapper{scrollbar-width:none;-ms-overflow-style:none;overflow:auto}.swiper-css-mode>.swiper-wrapper::-webkit-scrollbar{display:none}.swiper-css-mode>.swiper-wrapper>.swiper-slide{scroll-snap-align:start start}.swiper-css-mode.swiper-horizontal>.swiper-wrapper{scroll-snap-type:x mandatory}.swiper-css-mode.swiper-vertical>.swiper-wrapper{scroll-snap-type:y mandatory}.swiper-css-mode.swiper-free-mode>.swiper-wrapper{scroll-snap-type:none}.swiper-css-mode.swiper-free-mode>.swiper-wrapper>.swiper-slide{scroll-snap-align:none}.swiper-css-mode.swiper-centered>.swiper-wrapper:before{content:"";flex-shrink:0;order:9999}.swiper-css-mode.swiper-centered>.swiper-wrapper>.swiper-slide{scroll-snap-align:center center;scroll-snap-stop:always}.swiper-css-mode.swiper-centered.swiper-horizontal>.swiper-wrapper>.swiper-slide:first-child:dir(ltr){margin-left:var(--swiper-centered-offset-before)}.swiper-css-mode.swiper-centered.swiper-horizontal>.swiper-wrapper>.swiper-slide:first-child:dir(rtl){margin-right:var(--swiper-centered-offset-before)}.swiper-css-mode.swiper-centered.swiper-horizontal>.swiper-wrapper:before{height:100%;min-height:1px;width:var(--swiper-centered-offset-after)}.swiper-css-mode.swiper-centered.swiper-vertical>.swiper-wrapper>.swiper-slide:first-child{margin-top:var(--swiper-centered-offset-before)}.swiper-css-mode.swiper-centered.swiper-vertical>.swiper-wrapper:before{width:100%;min-width:1px;height:var(--swiper-centered-offset-after)}.swiper-3d .swiper-slide-shadow,.swiper-3d .swiper-slide-shadow-left,.swiper-3d .swiper-slide-shadow-right,.swiper-3d .swiper-slide-shadow-top,.swiper-3d .swiper-slide-shadow-bottom,.swiper-3d .swiper-slide-shadow,.swiper-3d .swiper-slide-shadow-left,.swiper-3d .swiper-slide-shadow-right,.swiper-3d .swiper-slide-shadow-top,.swiper-3d .swiper-slide-shadow-bottom{pointer-events:none;z-index:10;width:100%;height:100%;position:absolute;top:0;left:0}.swiper-3d .swiper-slide-shadow{background:#00000026}.swiper-3d .swiper-slide-shadow-left{background-image:linear-gradient(270deg,#00000080,#0000)}.swiper-3d .swiper-slide-shadow-right{background-image:linear-gradient(90deg,#00000080,#0000)}.swiper-3d .swiper-slide-shadow-top{background-image:linear-gradient(#0000,#00000080)}.swiper-3d .swiper-slide-shadow-bottom{background-image:linear-gradient(#00000080,#0000)}.swiper-lazy-preloader{z-index:10;transform-origin:50%;box-sizing:border-box;border:4px solid var(--swiper-preloader-color,var(--swiper-theme-color));border-top-color:#0000;border-radius:50%;width:42px;height:42px;margin-top:-21px;margin-left:-21px;position:absolute;top:50%;left:50%}.swiper:not(.swiper-watch-progress) .swiper-lazy-preloader,.swiper-watch-progress .swiper-slide-visible .swiper-lazy-preloader{animation:1s linear infinite swiper-preloader-spin}.swiper-lazy-preloader-white{--swiper-preloader-color:#fff}.swiper-lazy-preloader-black{--swiper-preloader-color:#000}@keyframes swiper-preloader-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes bgaZlG_businessTooltipIn{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bgaZlG_businessTooltipOut{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.9)}}.bgaZlG_tooltipContent{transform-origin:var(--radix-tooltip-content-transform-origin)}.bgaZlG_tooltipOpen{animation:.18s ease-out both bgaZlG_businessTooltipIn}.bgaZlG_tooltipClosing{pointer-events:none;animation:.14s ease-in both bgaZlG_businessTooltipOut}.mapboxgl-ctrl-logo{display:none!important}.mapboxgl-ctrl-group{background-clip:padding-box!important;border:1px solid #0d0d0d1a!important;border-radius:20px!important;box-shadow:0 1px 2px #00000026!important}.dark .mapboxgl-ctrl-group{opacity:.9!important;background-color:#000!important}.mapboxgl-ctrl-group button{width:36px!important;height:36px!important}.mapboxgl-ctrl-group button+button{border-top-color:#0d0d0d0d!important}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url(data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyMCIKICBoZWlnaHQ9IjIwIgogIHZpZXdCb3g9IjAgMCAyMCAyMCIKICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgo+CiAgPHBhdGgKICAgIGQ9Ik05LjMzNDk2IDE2LjVWMTAuNjY1SDMuNUMzLjEzMjczIDEwLjY2NSAyLjgzNDk2IDEwLjM2NzMgMi44MzQ5NiAxMEMyLjgzNDk2IDkuNjMyNzMgMy4xMzI3MyA5LjMzNDk2IDMuNSA5LjMzNDk2SDkuMzM0OTZWMy41QzkuMzM0OTYgMy4xMzI3MyA5LjYzMjczIDIuODM0OTYgMTAgMi44MzQ5NkMxMC4zNjczIDIuODM0OTYgMTAuNjY1IDMuMTMyNzMgMTAuNjY1IDMuNVY5LjMzNDk2SDE2LjVMMTYuNjMzOCA5LjM0ODYzQzE2LjkzNjkgOS40MTA1NyAxNy4xNjUgOS42Nzg1NyAxNy4xNjUgMTBDMTcuMTY1IDEwLjMyMTQgMTYuOTM2OSAxMC41ODk0IDE2LjYzMzggMTAuNjUxNEwxNi41IDEwLjY2NUgxMC42NjVWMTYuNUMxMC42NjUgMTYuODY3MyAxMC4zNjczIDE3LjE2NSAxMCAxNy4xNjVDOS42MzI3MyAxNy4xNjUgOS4zMzQ5NiAxNi44NjczIDkuMzM0OTYgMTYuNVoiCiAgLz4KPC9zdmc+Cg==)!important;background-size:18px 18px!important}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url(data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyMCIKICBoZWlnaHQ9IjIwIgogIHZpZXdCb3g9IjAgMCAyMCAyMCIKICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgo+CiAgPHBhdGgKICAgIGQ9Ik0xNi41IDkuMzM0OTZMMTYuNjMzOCA5LjM0ODYzQzE2LjkzNjkgOS40MTA1NyAxNy4xNjUgOS42Nzg1NyAxNy4xNjUgMTBDMTcuMTY1IDEwLjMyMTQgMTYuOTM2OSAxMC41ODk0IDE2LjYzMzggMTAuNjUxNEwxNi41IDEwLjY2NUgzLjVDMy4xMzI3MyAxMC42NjUgMi44MzQ5NiAxMC4zNjczIDIuODM0OTYgMTBDMi44MzQ5NiA5LjYzMjczIDMuMTMyNzMgOS4zMzQ5NiAzLjUgOS4zMzQ5NkgxNi41WiIKICAvPgo8L3N2Zz4K)!important;background-size:18px 18px!important}.dark .mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon,.dark .mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{filter:invert()!important}.mapboxgl-scroll-zoom-blocker{background-color:#0d0d0d!important;border-radius:9999px!important;width:auto!important;height:auto!important;padding:6px 16px!important;font-size:13px!important;font-weight:500!important;top:auto!important;bottom:10px!important;left:auto!important;right:10px!important}.BqefNq_userMarkerPulse{will-change:transform;animation:1.8s cubic-bezier(.4,0,.6,1) infinite BqefNq_userMarkerPulse}@keyframes BqefNq_userMarkerPulse{0%,to{transform:scale(1)}50%{transform:scale(1.1)}}.SceYza_memoryFootnoteGlowIn{animation:.22s ease-out SceYza_memoryFootnoteGlowIn}.SceYza_memoryFootnoteShimmerOnce{animation-iteration-count:1!important}@keyframes SceYza_memoryFootnoteGlowIn{0%{opacity:.75;transform:translateY(1px)scale(.985)}to{opacity:1;transform:translateY(0)scale(1)}}.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar{--vt_model_picker:model-picker;--vt_share_chat_wide_button:share-chat-wide-button;--vt_share_chat_compact_button:share-chat-compact-button;--vt_thread_tools:thread-tools;--vt-thread-header-open-canvas:open-canvas-button;--thread-extended-info-transition-name:thread-extended-info;--vt-disable-screen-column-transition:none;--vt_toggle_sidebar_opened:toggle-sidebar-icon-opened;--vt_toggle_sidebar_closed:toggle-sidebar-icon-closed;--vt-composer-speech-button:composer-speech-button;--vt_new_chat_thread:new-chat-thread;--vt-profile-avatar-thread:profile-avatar-active}@media (prefers-reduced-motion:reduce){:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition{display:none}}:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-group(*),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-old(*),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-new(*){animation-duration:var(--vt-duration,.3s);animation-timing-function:var(--vt-timing-function,var(--spring-common))}:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-old(IGEM1a_model-picker),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-old(IGEM1a_toggle-sidebar-icon),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-old(IGEM1a_share-chat-wide-button),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-old(IGEM1a_share-chat-compact-button),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-old(IGEM1a_thread-tools),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-old(IGEM1a_open-canvas-button),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-old(IGEM1a_composer-speech-button){display:none}:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-new(IGEM1a_model-picker),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-old(IGEM1a_toggle-sidebar-icon),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-new(IGEM1a_share-chat-wide-button),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-new(IGEM1a_share-chat-compact-button),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-new(IGEM1a_thread-tools),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-new(IGEM1a_open-canvas-button),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-new(IGEM1a_composer-speech-button){height:100%;animation:none}:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-group(IGEM1a_profile-avatar-active){z-index:2;animation:none}:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-new(IGEM1a_profile-avatar-active){animation:none}:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-old(IGEM1a_thread-extended-info),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-new(IGEM1a_thread-extended-info){object-fit:none;height:100%;overflow:clip}:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-old(IGEM1a_thread),:is(.active-view-transition.open-thread-sidebar,.active-view-transition.close-thread-sidebar)::view-transition-new(IGEM1a_thread){object-fit:none;height:100%;overflow:clip}.active-view-transition.open-thread-sidebar::view-transition-old(IGEM1a_thread-extended-info){display:none}.active-view-transition.close-thread-sidebar::view-transition-new(IGEM1a_thread-extended-info){display:none}@keyframes BZ_Pyq_fade-in{to{opacity:1}}.BZ_Pyq_root .BZ_Pyq_fadeIn,.BZ_Pyq_root hr,.BZ_Pyq_root li,.BZ_Pyq_root tr,.BZ_Pyq_root blockquote,.BZ_Pyq_root code,.BZ_Pyq_root pre{opacity:0;animation:BZ_Pyq_fade-in var(--duration,.7s) cubic-bezier(.37, .55, .86, .88) forwards}@media (prefers-reduced-motion:reduce){.BZ_Pyq_root .BZ_Pyq_fadeIn,.BZ_Pyq_root hr,.BZ_Pyq_root li,.BZ_Pyq_root tr,.BZ_Pyq_root blockquote,.BZ_Pyq_root code,.BZ_Pyq_root pre{--duration:0s;opacity:1}}@keyframes QKycbG_fade{0%{opacity:0}to{opacity:1}}.QKycbG_markdown.markdown .katex-error{display:none}.QKycbG_markdown.markdown .katex-display{opacity:0;animation:.4s 50ms forwards QKycbG_fade}.QKycbG_markdown.markdown p{margin-bottom:0!important}@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-outline-style:solid;--tw-border-style:solid;--tw-shadow:0 0 transparent;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 transparent;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 transparent;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 transparent;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 transparent;--tw-leading:initial;--tw-content:""}}}.wcDTda_prosemirror-parent .ProseMirror[contenteditable]{outline-style:var(--tw-outline-style);--tw-outline-style:none;outline-width:0;outline-style:none}.wcDTda_fallbackTextarea,.wcDTda_prosemirror-parent .ProseMirror{margin-top:calc(var(--spacing,.25rem)*4);margin-bottom:calc(var(--spacing,.25rem)*0);padding-inline:calc(var(--spacing,.25rem)*0);padding-top:calc(var(--spacing,.25rem)*0);padding-bottom:calc(var(--spacing,.25rem)*4);word-wrap:break-word;white-space:pre-wrap;white-space:break-spaces;-webkit-font-variant-ligatures:none;font-variant-ligatures:none;font-feature-settings:"liga" 0;transform:translateY(-.5px)}.wcDTda_fallbackTextarea{box-sizing:content-box;height:calc(var(--spacing,.25rem)*10);resize:none;border-style:var(--tw-border-style);width:100%;padding-inline:calc(var(--spacing,.25rem)*0);color:var(--text-primary);--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);background-color:#0000;border-width:0;display:block}.wcDTda_fallbackTextarea::placeholder{color:var(--text-tertiary)}.wcDTda_fallbackTextarea:dir(ltr)::placeholder{padding-left:1px}.wcDTda_fallbackTextarea:dir(rtl)::placeholder{padding-right:1px}.wcDTda_fallbackTextarea{height:1lh}@container wcDTda_thread (width>=640px){:is(.wcDTda_prosemirror-parent[data-size=lg] .ProseMirror,.wcDTda_prosemirror-parent[data-size=lg] .wcDTda_fallbackTextarea){margin-top:calc(var(--spacing,.25rem)*3.5);padding-bottom:calc(var(--spacing,.25rem)*3.5);font-size:var(--text-lg,1.125rem);line-height:var(--tw-leading,var(--text-lg--line-height,calc(1.75/1.125)));white-space:pre-wrap}:is(.wcDTda_prosemirror-parent[data-size=xl] .ProseMirror,.wcDTda_prosemirror-parent[data-size=xl] .wcDTda_fallbackTextarea){margin-top:calc(var(--spacing,.25rem)*3.5);padding-bottom:calc(var(--spacing,.25rem)*3.5);font-size:var(--text-xl,1.25rem);line-height:var(--tw-leading,var(--text-xl--line-height,calc(1.75/1.25)));white-space:pre-wrap}}.wcDTda_prosemirror-parent.ProseMirror br{--tw-leading:normal;line-height:normal}.wcDTda_prosemirror-parent.default-browser .placeholder:after{pointer-events:none;cursor:text;color:var(--text-tertiary);--tw-content:attr(data-placeholder);content:var(--tw-content);padding-left:1px;position:relative}.wcDTda_prosemirror-parent.firefox .placeholder:before{pointer-events:none;cursor:text;color:var(--text-tertiary);--tw-content:attr(data-placeholder);content:var(--tw-content);padding-left:1px;position:absolute}.wcDTda_prosemirror-parent.default-browser .placeholder .ProseMirror-trailingBreak{display:none!important}.wcDTda_prosemirror-parent p{white-space:pre-wrap}.wcDTda_prosemirror-parent p.placeholder{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.screen-arch .wcDTda_prosemirror-parent p.placeholder{width:-webkit-fit-content;width:fit-content;view-transition-name:var(--vt-composer-placeholder)}.wcDTda_prosemirror-parent .ProseMirror-separator{display:none!important}.wcDTda_prosemirror-parent .pm-bracket-tag{cursor:text;border-radius:var(--radius-md,.375rem);text-overflow:ellipsis;white-space:nowrap;color:#2f7cf5;background-color:#2f7cf51a;background-color:lab(52.5277% 10.4527 -68.6236/.1);align-items:center;max-width:16rem;display:inline-flex;overflow:hidden}@media (hover:hover){.wcDTda_prosemirror-parent .pm-bracket-tag:hover{color:#0285ff;background-color:#e5f3ff}}.wcDTda_prosemirror-parent .pm-bracket-tag{padding:3px 6px}.wcDTda_prosemirror-parent .pm-bracket-tag[data-template-active=true]{box-shadow:0 0 0 1px var(--interactive-border-focus);background-color:var(--interactive-bg-accent-muted-hover)}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 transparent}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 transparent}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 transparent}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 transparent}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 transparent}@property --tw-leading{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}.R6Vx5W_threadRoot{--thread-safe-area-height:calc(100lvh - var(--thread-safe-area-inset-top) - var(--thread-safe-area-inset-bottom));--thread-safe-area-inset-top:calc(var(--header-height) + env(safe-area-inset-top,0px));--thread-safe-area-inset-bottom:calc(var(--thread-footer-height,150px) + var(--screen-keyboard-height,0px) + env(safe-area-inset-bottom,0px))}.R6Vx5W_threadGutter{--thread-end-gutter-active-height:calc(var(--thread-safe-area-height) - var(--thread-stream-context-height) - 2 * var(--thread-turn-vertical-padding));--thread-stream-context-height:max(2.75rem + 2 * var(--thread-turn-vertical-padding), 1/3 * var(--thread-safe-area-height));--thread-turn-vertical-padding:1.25rem}._69lA8a_main .prose{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}@supports selector(::scroll-button(*)){@media (pointer:fine){.xuHWuq_carousel{scrollbar-width:none;scroll-padding-inline:calc(9 * var(--spacing));scroll-snap-type:x mandatory;scroll-behavior:smooth}@media (prefers-reduced-motion:reduce){.xuHWuq_carousel{scroll-behavior:auto}}.xuHWuq_carousel::scroll-button(*){aspect-ratio:1;background-color:var(--main-surface-primary);border:1px solid var(--border-medium);color:var(--text-primary);width:calc(9 * var(--spacing));cursor:pointer;border-radius:50%;justify-content:center;align-items:center;line-height:0;transition:opacity .15s ease-in-out;display:flex;position:absolute;top:50%}@starting-style{.xuHWuq_carousel::scroll-button(*){opacity:0}}.xuHWuq_carousel::scroll-button(*):hover{background-color:var(--main-surface-secondary)}.xuHWuq_carousel::scroll-button(*):disabled{opacity:0;pointer-events:none}.xuHWuq_carousel::scroll-button(left){content:url("data:image/svg+xml;utf8,");left:0;transform:translate(-50%,-50%)}@container (width<43rem){.xuHWuq_carousel::scroll-button(left){transform:translate(calc(-1 * (var(--thread-content-margin) - 2 * var(--spacing))), -50%)}}.dark .xuHWuq_carousel::scroll-button(left){content:url("data:image/svg+xml;utf8,")}.xuHWuq_carousel::scroll-button(right){content:url("data:image/svg+xml;utf8,");right:0;transform:translate(50%,-50%)}@container (width<43rem){.xuHWuq_carousel::scroll-button(right){transform:translate(calc(var(--thread-content-margin) - 2 * var(--spacing)), -50%)}}.dark .xuHWuq_carousel::scroll-button(right){content:url("data:image/svg+xml;utf8,")}}}@keyframes sPZ93q_add-top-shadow{0%{box-shadow:0 1px #0000}.1%,to{box-shadow:0 1px 0 var(--border-sharp)}}@keyframes sPZ93q_add-bottom-shadow{0%,99.9%{box-shadow:0 -1px 0 var(--border-sharp)}to{box-shadow:0 -1px #0000}}.sPZ93q_leadingBar{animation:linear both sPZ93q_add-top-shadow;box-shadow:0 1px #0000}.sPZ93q_leadingBarScrollAnimation{animation-timeline:scroll()}.sPZ93q_trailingBar{animation:linear both sPZ93q_add-bottom-shadow;box-shadow:0 -1px #0000}.sPZ93q_trailingBarScrollAnimation{animation-timeline:scroll()}.sPZ93q_primary{background-color:var(--bar-background-color,var(--main-surface-primary))}._56rfYG_screen{display:var(--screen-display,grid);grid-template:"_56rfYG_leading"max-content"_56rfYG_content"1fr"_56rfYG_trailing"max-content"_56rfYG_keyboard"/minmax(0,1fr)}@supports not (overflow:clip){._56rfYG_screen{overflow:var(--screen-overflow,hidden auto)}}@supports (overflow:clip){._56rfYG_screen{overflow:var(--screen-overflow,clip auto)}}._56rfYG_screen{scrollbar-gutter:var(--screen-scrollbar-gutter-override,stable);padding-top:calc(var(--screen-anchor-top) + var(--screen-top-offset,0px));width:100%}._56rfYG_screen [slot=content]{padding-inline:var(--screen-content-inline-padding,var(--screen-inline-padding));position:var(--screen-content-position,relative);grid-area:_56rfYG_content}._56rfYG_screen [slot=leading]{min-width:var(--screen-leading-slot-min-width);overflow:var(--screen-leading-slot-overflow);top:var(--screen-leading-slot-top,0);z-index:var(--screen-leading-slot-z-index,20);grid-area:_56rfYG_leading;position:-webkit-sticky;position:sticky}._56rfYG_screen [slot=trailing]{bottom:var(--keyboard-safe-area-bottom,0);padding-inline:var(--screen-trailing-inline-padding,var(--screen-inline-padding));z-index:var(--screen-leading-slot-z-index,20);grid-area:_56rfYG_trailing;position:-webkit-sticky;position:sticky}._56rfYG_screen [slot=keyboard]{height:var(--keyboard-safe-area-bottom,0px);background:#fcfcfc;grid-area:_56rfYG_keyboard;position:-webkit-sticky;position:sticky;bottom:0}._56rfYG_screen:where([screen-anchor=vertical],[screen-anchor=top]){--safe-area-top:calc(env(_56rfYG_titlebar-area-y,0px) + env(safe-area-inset-top,0px));--screen-anchor-top:var(--safe-area-top)}._56rfYG_screen:where([screen-anchor=vertical],[screen-anchor=bottom]){--safe-area-bottom:env(safe-area-inset-bottom,0px);--keyboard-safe-area-bottom:max(var(--screen-keyboard-height), env(_56rfYG_keyboard-inset-height,0px));--screen-anchor-bottom:var(--safe-area-bottom)} +.OeMrba_wrapper { + cursor: pointer; + -webkit-user-select: none; + user-select: none; + border-radius: 8px; + width: 44px; + height: 44px; + display: inline-flex; +} +.OeMrba_wrapper > input[type='checkbox'] { + display: none; +} +.OeMrba_wrapper { + color: var(--icon-secondary); +} +@media (hover: hover) and (pointer: fine) { + .OeMrba_wrapper:hover:not(.OeMrba_disabled) { + --hover-background: var(--main-surface-secondary); + } +} +.OeMrba_label { + background-color: var(--hover-background); + color: var(--text-secondary); + border-radius: 8px; + flex: 1; + justify-content: center; + align-items: center; + transition: background-color 0.1s linear; + display: flex; +} +@keyframes sR_mOW_slide-up { + 0% { + opacity: 0; + translate: 0 20vw; + } +} +@keyframes sR_mOW_slide-down { + to { + opacity: 0; + translate: 0 20vw; + } +} +@keyframes sR_mOW_pinnedOldFastFade { + 0% { + opacity: 1; + } + 20% { + opacity: 0; + } + to { + opacity: 0; + } +} +@view-transition { +} +.sR_mOW_page-to-page-transition { + view-transition-name: none; +} +.sR_mOW_page-to-page-transition body { + view-transition-name: sR_mOW_page; +} +@media (prefers-reduced-motion: reduce) { + .sR_mOW_page-to-page-transition::view-transition { + display: none; + } +} +.sR_mOW_page-to-page-transition::view-transition-old(sR_mOW_header), +.sR_mOW_page-to-page-transition::view-transition-old(sR_mOW_sidebar) { + display: none; +} +.sR_mOW_page-to-page-transition::view-transition-new(sR_mOW_header), +.sR_mOW_page-to-page-transition::view-transition-new(sR_mOW_sidebar) { + animation: none; +} +.sR_mOW_page-to-page-transition::view-transition-image-pair(sR_mOW_active-image), +.sR_mOW_page-to-page-transition::view-transition-old(sR_mOW_active-image), +.sR_mOW_page-to-page-transition::view-transition-new(sR_mOW_active-image) { + height: 100%; +} +.sR_mOW_page-to-page-transition::view-transition-image-pair(sR_mOW_page-title), +.sR_mOW_page-to-page-transition::view-transition-old(sR_mOW_page-title), +.sR_mOW_page-to-page-transition::view-transition-new(sR_mOW_page-title) { + height: 100%; +} +.sR_mOW_page-to-page-transition::view-transition-image-pair(sR_mOW_acive-image), +.sR_mOW_page-to-page-transition::view-transition-old(sR_mOW_acive-image), +.sR_mOW_page-to-page-transition::view-transition-new(sR_mOW_acive-image) { + height: 100%; +} +.sR_mOW_page-to-page-transition::view-transition-group(*), +.sR_mOW_page-to-page-transition::view-transition-old(*), +.sR_mOW_page-to-page-transition::view-transition-new(*) { + animation-duration: 0.4s; + animation-timing-function: var(--spring-fast); +} +.sR_mOW_page-to-page-transition.from-library.to-conversation, +.sR_mOW_page-to-page-transition.from-lightbox.to-conversation { + --vt-active-image: active-image; +} +:is(.sR_mOW_page-to-page-transition.to-home, .sR_mOW_page-to-page-transition.from-landing-page)::view-transition-new( + sR_mOW_composer +) { + animation: none; +} +.sR_mOW_page-to-page-transition.to-landing-page, +.sR_mOW_page-to-page-transition.from-landing-page { + --vt-page-header: header; + --vt-splash-screen-headline: page-title; + --vt-tool-page-title: page-title; + --vt-composer: composer; + --sidebar-slideover: sidebar; +} +.sR_mOW_page-to-page-transition.to-landing-page::view-transition-new(sR_mOW_page) { + animation: sR_mOW_slide-up 0.4s var(--spring-fast); +} +.sR_mOW_composer-slide { + --vt-composer: composer; +} +.sR_mOW_composer-slide::view-transition-old(sR_mOW_composer), +.sR_mOW_composer-slide::view-transition-group(sR_mOW_composer) { + animation-duration: 0.5s; + animation-timing-function: var(--spring-fast); +} +.sR_mOW_grid-item { + --vt-grid-item: grid-item; +} +.sR_mOW_grid-item::view-transition-old(sR_mOW_grid-item), +.sR_mOW_grid-item::view-transition-new(sR_mOW_grid-item) { + object-fit: fill; + transform-origin: 50%; + width: 100%; + height: 100%; + animation-duration: 0.5s; + animation-timing-function: var(--spring-fast); +} +.sR_mOW_pinned-widget { + --vt-composer: composer; + --vt-disclaimer: disclaimer; +} +.sR_mOW_pinned-widget::view-transition, +.sR_mOW_pinned-widget::view-transition-group(sR_mOW_pinned-kanzi-widget), +.sR_mOW_pinned-widget::view-transition-image-pair(sR_mOW_pinned-kanzi-widget), +.sR_mOW_pinned-widget::view-transition-old(sR_mOW_pinned-kanzi-widget), +.sR_mOW_pinned-widget::view-transition-new(sR_mOW_pinned-kanzi-widget) { + background: 0 0; +} +.sR_mOW_pinned-widget::view-transition-old(sR_mOW_pinned-kanzi-widget) { + animation-name: sR_mOW_pinnedOldFastFade; + animation-duration: 0.1s; + animation-timing-function: var(--spring-fast); + object-fit: fill; + transform-origin: 50%; + width: 100%; + height: 100%; +} +.sR_mOW_pinned-widget::view-transition-new(sR_mOW_pinned-kanzi-widget) { + object-fit: fill; + transform-origin: 50%; + width: 100%; + height: 100%; + animation-duration: 0.2s; + animation-timing-function: var(--spring-fast); +} +.sR_mOW_pinned-widget::view-transition-old(sR_mOW_composer), +.sR_mOW_pinned-widget::view-transition-group(sR_mOW_composer) { + z-index: 999; +} +.sR_mOW_pinned-widget::view-transition-old(sR_mOW_disclaimer), +.sR_mOW_pinned-widget::view-transition-group(sR_mOW_disclaimer) { + z-index: 999; +} +.sR_mOW_pinned-widget.sR_mOW_becoming-inline::view-transition-old(sR_mOW_pinned-kanzi-widget) { + animation-duration: 0.25s; + animation-delay: 0.1s; +} +.sR_mOW_pinned-widget.sR_mOW_becoming-inline::view-transition-new(sR_mOW_pinned-kanzi-widget) { + animation-duration: 50ms; + animation-delay: 0.1s; +} +.sR_mOW_becoming-inline { + --vt-becoming-inline: 1; +} +.sR_mOW_pinned-widget.sR_mOW_becoming-pinned::view-transition-old(sR_mOW_pinned-kanzi-widget) { + animation-duration: 0.35s; + animation-delay: 0s; + animation-timing-function: var(--spring-fast); +} +.sR_mOW_pinned-widget.sR_mOW_becoming-pinned::view-transition-new(sR_mOW_pinned-kanzi-widget) { + animation-duration: 0.1s; + animation-delay: 0s; + animation-timing-function: var(--spring-fast); +} +.sR_mOW_becoming-pinned { + --vt-becoming-pinned: 1; +} +.sR_mOW_fullscreen-widget-composer-transition { + --vt-composer: composer; +} +.sR_mOW_fullscreen-widget-composer-transition::view-transition-old(sR_mOW_composer), +.sR_mOW_fullscreen-widget-composer-transition::view-transition-new(sR_mOW_composer) { + object-fit: fill; + transform-origin: 50%; + width: 100%; + height: 100%; + animation-duration: 0.25s; + animation-timing-function: var(--spring-fast); +} +.sR_mOW_places-overlay-transition { + --vt-composer: places-overlay-composer; +} +.sR_mOW_places-overlay-transition::view-transition-group(sR_mOW_business-list-container), +.sR_mOW_places-overlay-transition::view-transition-old(sR_mOW_business-list-container), +.sR_mOW_places-overlay-transition::view-transition-new(sR_mOW_business-list-container) { + opacity: 1; + animation: none; +} +.sR_mOW_places-overlay-transition::view-transition-group(sR_mOW_map-with-entities), +.sR_mOW_places-overlay-transition::view-transition-old(sR_mOW_map-with-entities), +.sR_mOW_places-overlay-transition::view-transition-new(sR_mOW_map-with-entities) { + z-index: 0; +} +.sR_mOW_places-overlay-transition::view-transition-group(sR_mOW_places-overlay-composer), +.sR_mOW_places-overlay-transition::view-transition-old(sR_mOW_places-overlay-composer), +.sR_mOW_places-overlay-transition::view-transition-new(sR_mOW_places-overlay-composer) { + z-index: 999; + animation-duration: 0.22s; + animation-timing-function: var(--spring-fast); + object-fit: fill; + transform-origin: bottom; + width: 100%; + height: 100%; +} +.sR_mOW_fullscreen-popover-thread-opening-transition, +.sR_mOW_fullscreen-popover-thread-closing-transition { + --vt-composer: composer; +} +:is( + .sR_mOW_fullscreen-popover-thread-opening-transition, + .sR_mOW_fullscreen-popover-thread-closing-transition +)::view-transition-old(sR_mOW_composer), +:is( + .sR_mOW_fullscreen-popover-thread-opening-transition, + .sR_mOW_fullscreen-popover-thread-closing-transition +)::view-transition-group(sR_mOW_composer) { + z-index: 999; +} +.sR_mOW_fullscreen-popover-thread-opening-transition::view-transition-new(sR_mOW_fullscreen-popover-thread) { + z-index: 0; + transform-origin: bottom; + animation: sR_mOW_popover-thread-enter 0.3s var(--spring-fast) both; +} +.sR_mOW_fullscreen-popover-thread-closing-transition::view-transition-old(sR_mOW_fullscreen-popover-thread) { + z-index: 0; + transform-origin: bottom; + animation: sR_mOW_popover-thread-exit 0.3s var(--spring-fast) both; +} +@keyframes sR_mOW_popover-thread-enter { + 0% { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes sR_mOW_popover-thread-exit { + 0% { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.98); + } +} +.mapboxgl-map { + -webkit-tap-highlight-color: transparent; + font: + 12px/20px Helvetica Neue, + Arial, + Helvetica, + sans-serif; + position: relative; + overflow: hidden; +} +.mapboxgl-canvas { + position: absolute; + top: 0; + left: 0; +} +.mapboxgl-map:-webkit-full-screen { + width: 100%; + height: 100%; +} +.mapboxgl-canary { + background-color: salmon; +} +.mapboxgl-canvas-container.mapboxgl-interactive, +.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass { + cursor: grab; + -webkit-user-select: none; + user-select: none; +} +.mapboxgl-canvas-container.mapboxgl-interactive.mapboxgl-track-pointer { + cursor: pointer; +} +.mapboxgl-canvas-container.mapboxgl-interactive:active, +.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass:active { + cursor: grabbing; +} +.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate, +.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate .mapboxgl-canvas { + touch-action: pan-x pan-y; +} +.mapboxgl-canvas-container.mapboxgl-touch-drag-pan, +.mapboxgl-canvas-container.mapboxgl-touch-drag-pan .mapboxgl-canvas { + touch-action: pinch-zoom; +} +.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan, +.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan .mapboxgl-canvas { + touch-action: none; +} +.mapboxgl-ctrl-bottom, +.mapboxgl-ctrl-bottom-left, +.mapboxgl-ctrl-bottom-right, +.mapboxgl-ctrl-left, +.mapboxgl-ctrl-right, +.mapboxgl-ctrl-top, +.mapboxgl-ctrl-top-left, +.mapboxgl-ctrl-top-right { + pointer-events: none; + z-index: 2; + position: absolute; +} +.mapboxgl-ctrl-top-left { + top: 0; + left: 0; +} +.mapboxgl-ctrl-top { + top: 0; + left: 50%; + transform: translate(-50%); +} +.mapboxgl-ctrl-top-right { + top: 0; + right: 0; +} +.mapboxgl-ctrl-right { + top: 50%; + right: 0; + transform: translateY(-50%); +} +.mapboxgl-ctrl-bottom-right { + bottom: 0; + right: 0; +} +.mapboxgl-ctrl-bottom { + bottom: 0; + left: 50%; + transform: translate(-50%); +} +.mapboxgl-ctrl-bottom-left { + bottom: 0; + left: 0; +} +.mapboxgl-ctrl-left { + top: 50%; + left: 0; + transform: translateY(-50%); +} +.mapboxgl-ctrl { + clear: both; + pointer-events: auto; + transform: translate(0); +} +.mapboxgl-ctrl-top-left .mapboxgl-ctrl { + float: left; + margin: 10px 0 0 10px; +} +.mapboxgl-ctrl-top .mapboxgl-ctrl { + float: left; + margin: 10px 0; +} +.mapboxgl-ctrl-top-right .mapboxgl-ctrl { + float: right; + margin: 10px 10px 0 0; +} +.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl, +.mapboxgl-ctrl-right .mapboxgl-ctrl { + float: right; + margin: 0 10px 10px 0; +} +.mapboxgl-ctrl-bottom .mapboxgl-ctrl { + float: left; + margin: 10px 0; +} +.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl, +.mapboxgl-ctrl-left .mapboxgl-ctrl { + float: left; + margin: 0 0 10px 10px; +} +.mapboxgl-ctrl-group { + background: #fff; + border-radius: 4px; +} +.mapboxgl-ctrl-group:not(:empty) { + box-shadow: 0 0 0 2px #0000001a; +} +@media (-ms-high-contrast: active) { + .mapboxgl-ctrl-group:not(:empty) { + box-shadow: 0 0 0 2px buttontext; + } +} +.mapboxgl-ctrl-group button { + background-color: initial; + box-sizing: border-box; + cursor: pointer; + border: 0; + outline: none; + width: 29px; + height: 29px; + padding: 0; + display: block; + overflow: hidden; +} +.mapboxgl-ctrl-group button + button { + border-top: 1px solid #ddd; +} +.mapboxgl-ctrl button .mapboxgl-ctrl-icon { + background-position: 50%; + background-repeat: no-repeat; + width: 100%; + height: 100%; + display: block; +} +@media (-ms-high-contrast: active) { + .mapboxgl-ctrl-icon { + background-color: initial; + } + .mapboxgl-ctrl-group button + button { + border-top: 1px solid buttontext; + } +} +.mapboxgl-ctrl-attrib-button:focus, +.mapboxgl-ctrl-group button:focus { + box-shadow: 0 0 2px 2px #0096ff; +} +.mapboxgl-ctrl button:disabled { + cursor: not-allowed; +} +.mapboxgl-ctrl button:disabled .mapboxgl-ctrl-icon { + opacity: 0.25; +} +.mapboxgl-ctrl-group button:first-child { + border-radius: 4px 4px 0 0; +} +.mapboxgl-ctrl-group button:last-child { + border-radius: 0 0 4px 4px; +} +.mapboxgl-ctrl-group button:only-child { + border-radius: inherit; +} +.mapboxgl-ctrl button:not(:disabled):hover { + background-color: #0000000d; +} +.mapboxgl-ctrl-group button:focus:focus-visible { + box-shadow: 0 0 2px 2px #0096ff; +} +.mapboxgl-ctrl-group button:focus:not(:focus-visible) { + box-shadow: none; +} +.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E"); +} +.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E"); +} +@media (-ms-high-contrast: active) { + .mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E"); + } + .mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E"); + } +} +@media (-ms-high-contrast: black-on-white) { + .mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E"); + } + .mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E"); + } +} +.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E"); +} +.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E"); +} +@media (-ms-high-contrast: active) { + .mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E"); + } + .mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E"); + } +} +@media (-ms-high-contrast: black-on-white) { + .mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E"); + } + .mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E"); + } +} +.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E"); +} +@media (-ms-high-contrast: active) { + .mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23999'/%3E%3C/svg%3E"); + } +} +@media (-ms-high-contrast: black-on-white) { + .mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E"); + } +} +.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E"); +} +.mapboxgl-ctrl button.mapboxgl-ctrl-buildings-toggle .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg fill='none' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath d='M3.3335 11.6666C3.3335 12.5871 4.07969 13.3333 5.00016 13.3333C5.92064 13.3333 6.66683 12.5871 6.66683 11.6666L6.66683 6.66659L11.6668 6.66659C12.5873 6.66659 13.3335 5.92039 13.3335 4.99992C13.3335 4.07944 12.5873 3.33325 11.6668 3.33325H3.3335V11.6666Z' fill='currentColor'/%3E%3Cpath d='M26.6668 11.6666C26.6668 12.5871 25.9206 13.3333 25.0002 13.3333C24.0797 13.3333 23.3335 12.5871 23.3335 11.6666L23.3335 6.66659L18.3335 6.66659C17.413 6.66659 16.6668 5.92039 16.6668 4.99992C16.6668 4.07944 17.413 3.33325 18.3335 3.33325H26.6668L26.6668 11.6666Z' fill='currentColor'/%3E%3Cpath d='M13.3335 24.9999C13.3335 25.9204 12.5873 26.6666 11.6668 26.6666H3.3335V18.3333C3.3335 17.4128 4.07969 16.6666 5.00016 16.6666C5.92064 16.6666 6.66683 17.4128 6.66683 18.3333V23.3333H11.6668C12.5873 23.3333 13.3335 24.0794 13.3335 24.9999Z' fill='currentColor'/%3E%3Cpath d='M18.3335 26.6666C17.413 26.6666 16.6668 25.9204 16.6668 24.9999C16.6668 24.0794 17.413 23.3333 18.3335 23.3333H23.3335V18.3333C23.3335 17.4128 24.0797 16.6666 25.0002 16.6666C25.9206 16.6666 26.6668 17.4128 26.6668 18.3333V26.6666H18.3335Z' fill='currentColor'/%3E%3C/svg%3E"); + background-size: 26px 26px; +} +.mapboxgl-ctrl button.mapboxgl-ctrl-buildings-toggle.mapboxgl-ctrl-level-button-selected .mapboxgl-ctrl-icon { + filter: invert() brightness(); +} +.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23aaa'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E"); +} +.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E"); +} +.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E"); +} +.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E"); +} +.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E"); +} +.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-waiting .mapboxgl-ctrl-icon { + animation: 2s linear infinite mapboxgl-spin; +} +@media (-ms-high-contrast: active) { + .mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E"); + } + .mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23999'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E"); + } + .mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E"); + } + .mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E"); + } + .mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E"); + } + .mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E"); + } +} +@media (-ms-high-contrast: black-on-white) { + .mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E"); + } + .mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23666'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E"); + } +} +@keyframes mapboxgl-spin { + 0% { + transform: rotate(0); + } + to { + transform: rotate(1turn); + } +} +a.mapboxgl-ctrl-logo { + cursor: pointer; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='0.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='0.9' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E"); + background-repeat: no-repeat; + width: 88px; + height: 23px; + margin: 0 0 -4px -4px; + display: block; + overflow: hidden; +} +a.mapboxgl-ctrl-logo.mapboxgl-compact { + width: 23px; +} +@media (-ms-high-contrast: active) { + a.mapboxgl-ctrl-logo { + background-color: initial; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E"); + } +} +@media (-ms-high-contrast: black-on-white) { + a.mapboxgl-ctrl-logo { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23fff' stroke-width='3' fill='%23fff'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23000'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E"); + } +} +.mapboxgl-ctrl.mapboxgl-ctrl-attrib { + background-color: #ffffff80; + margin: 0; + padding: 0 5px; +} +@media screen { + .mapboxgl-ctrl-attrib.mapboxgl-compact { + box-sizing: initial; + background-color: #fff; + border-radius: 12px; + min-height: 20px; + margin: 10px; + padding: 2px 24px 2px 0; + position: relative; + } + .mapboxgl-ctrl-attrib.mapboxgl-compact-show { + visibility: visible; + padding: 2px 28px 2px 8px; + } + .mapboxgl-ctrl-bottom-left > .mapboxgl-ctrl-attrib.mapboxgl-compact-show, + .mapboxgl-ctrl-left > .mapboxgl-ctrl-attrib.mapboxgl-compact-show, + .mapboxgl-ctrl-top-left > .mapboxgl-ctrl-attrib.mapboxgl-compact-show { + border-radius: 12px; + padding: 2px 8px 2px 28px; + } + .mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-inner { + display: none; + } + .mapboxgl-ctrl-attrib-button { + box-sizing: border-box; + cursor: pointer; + background-color: #ffffff80; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E"); + border: 0; + border-radius: 12px; + outline: none; + width: 24px; + height: 24px; + display: none; + position: absolute; + top: 0; + right: 0; + } + .mapboxgl-ctrl-bottom-left .mapboxgl-ctrl-attrib-button, + .mapboxgl-ctrl-left .mapboxgl-ctrl-attrib-button, + .mapboxgl-ctrl-top-left .mapboxgl-ctrl-attrib-button { + left: 0; + } + .mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-button, + .mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-inner { + display: block; + } + .mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button { + background-color: #0000000d; + } + .mapboxgl-ctrl-bottom-right > .mapboxgl-ctrl-attrib.mapboxgl-compact:after { + bottom: 0; + right: 0; + } + .mapboxgl-ctrl-right > .mapboxgl-ctrl-attrib.mapboxgl-compact:after { + right: 0; + } + .mapboxgl-ctrl-top-right > .mapboxgl-ctrl-attrib.mapboxgl-compact:after { + top: 0; + right: 0; + } + .mapboxgl-ctrl-top-left > .mapboxgl-ctrl-attrib.mapboxgl-compact:after { + top: 0; + left: 0; + } + .mapboxgl-ctrl-bottom-left > .mapboxgl-ctrl-attrib.mapboxgl-compact:after { + bottom: 0; + left: 0; + } + .mapboxgl-ctrl-left > .mapboxgl-ctrl-attrib.mapboxgl-compact:after { + left: 0; + } +} +@media screen and (-ms-high-contrast: active) { + .mapboxgl-ctrl-attrib.mapboxgl-compact:after { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' fill='%23fff'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E"); + } +} +@media screen and (-ms-high-contrast: black-on-white) { + .mapboxgl-ctrl-attrib.mapboxgl-compact:after { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E"); + } +} +.mapboxgl-ctrl-attrib a { + color: #000000bf; + text-decoration: none; +} +.mapboxgl-ctrl-attrib a:hover { + color: inherit; + text-decoration: underline; +} +.mapboxgl-ctrl-attrib .mapbox-improve-map { + margin-left: 2px; + font-weight: 700; +} +.mapboxgl-attrib-empty { + display: none; +} +.mapboxgl-ctrl-scale { + box-sizing: border-box; + color: #333; + white-space: nowrap; + background-color: #ffffffbf; + border: 2px solid #333; + border-top: #333; + padding: 0 5px; + font-size: 10px; +} +.mapboxgl-popup { + pointer-events: none; + will-change: transform; + display: flex; + position: absolute; + top: 0; + left: 0; +} +.mapboxgl-popup-anchor-top, +.mapboxgl-popup-anchor-top-left, +.mapboxgl-popup-anchor-top-right { + flex-direction: column; +} +.mapboxgl-popup-anchor-bottom, +.mapboxgl-popup-anchor-bottom-left, +.mapboxgl-popup-anchor-bottom-right { + flex-direction: column-reverse; +} +.mapboxgl-popup-anchor-left { + flex-direction: row; +} +.mapboxgl-popup-anchor-right { + flex-direction: row-reverse; +} +.mapboxgl-popup-tip { + z-index: 1; + border: 10px solid #0000; + width: 0; + height: 0; +} +.mapboxgl-popup-anchor-top .mapboxgl-popup-tip { + border-top: none; + border-bottom-color: #fff; + align-self: center; +} +.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip { + border-top: none; + border-bottom-color: #fff; + border-left: none; + align-self: flex-start; +} +.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip { + border-top: none; + border-bottom-color: #fff; + border-right: none; + align-self: flex-end; +} +.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip { + border-top-color: #fff; + border-bottom: none; + align-self: center; +} +.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip { + border-top-color: #fff; + border-bottom: none; + border-left: none; + align-self: flex-start; +} +.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip { + border-top-color: #fff; + border-bottom: none; + border-right: none; + align-self: flex-end; +} +.mapboxgl-popup-anchor-left .mapboxgl-popup-tip { + border-left: none; + border-right-color: #fff; + align-self: center; +} +.mapboxgl-popup-anchor-right .mapboxgl-popup-tip { + border-left-color: #fff; + border-right: none; + align-self: center; +} +.mapboxgl-popup-close-button { + background-color: initial; + cursor: pointer; + border: 0; + border-radius: 0 3px 0 0; + position: absolute; + top: 0; + right: 0; +} +.mapboxgl-popup-close-button:hover { + background-color: #0000000d; +} +.mapboxgl-popup-content { + pointer-events: auto; + background: #fff; + border-radius: 3px; + padding: 10px 10px 15px; + position: relative; + box-shadow: 0 1px 2px #0000001a; +} +.mapboxgl-popup-anchor-top-left .mapboxgl-popup-content { + border-top-left-radius: 0; +} +.mapboxgl-popup-anchor-top-right .mapboxgl-popup-content { + border-top-right-radius: 0; +} +.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-content { + border-bottom-left-radius: 0; +} +.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-content { + border-bottom-right-radius: 0; +} +.mapboxgl-popup-track-pointer { + display: none; +} +.mapboxgl-popup-track-pointer * { + pointer-events: none; + -webkit-user-select: none; + user-select: none; +} +.mapboxgl-map:hover .mapboxgl-popup-track-pointer { + display: flex; +} +.mapboxgl-map:active .mapboxgl-popup-track-pointer { + display: none; +} +.mapboxgl-marker { + opacity: 1; + will-change: transform; + transition: opacity 0.2s; + position: absolute; + top: 0; + left: 0; +} +.mapboxgl-user-location-dot, +.mapboxgl-user-location-dot:before { + background-color: #1da1f2; + border-radius: 50%; + width: 15px; + height: 15px; +} +.mapboxgl-user-location-dot:before { + content: ''; + animation: 2s infinite mapboxgl-user-location-dot-pulse; + position: absolute; +} +.mapboxgl-user-location-dot:after { + box-sizing: border-box; + content: ''; + border: 2px solid #fff; + border-radius: 50%; + width: 19px; + height: 19px; + position: absolute; + top: -2px; + left: -2px; + box-shadow: 0 0 3px #00000059; +} +.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading { + width: 0; + height: 0; +} +.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after, +.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before { + content: ''; + border-bottom: 7.5px solid #4aa1eb; + position: absolute; +} +.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before { + border-left: 7.5px solid #0000; + transform: translateY(-28px) skewY(-20deg); +} +.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after { + border-right: 7.5px solid #0000; + transform: translate(7.5px, -28px) skewY(20deg); +} +@keyframes mapboxgl-user-location-dot-pulse { + 0% { + opacity: 1; + transform: scale(1); + } + 70% { + opacity: 0; + transform: scale(3); + } + to { + opacity: 0; + transform: scale(1); + } +} +.mapboxgl-user-location-dot-stale { + background-color: #aaa; +} +.mapboxgl-user-location-dot-stale:after { + display: none; +} +.mapboxgl-user-location-accuracy-circle { + background-color: #1da1f233; + border-radius: 100%; + width: 1px; + height: 1px; +} +.mapboxgl-crosshair, +.mapboxgl-crosshair .mapboxgl-interactive, +.mapboxgl-crosshair .mapboxgl-interactive:active { + cursor: crosshair; +} +.mapboxgl-boxzoom { + opacity: 0.5; + background: #fff; + border: 2px dotted #202020; + width: 0; + height: 0; + position: absolute; + top: 0; + left: 0; +} +@media print { + .mapbox-improve-map { + display: none; + } +} +.mapboxgl-scroll-zoom-blocker, +.mapboxgl-touch-pan-blocker { + color: #fff; + opacity: 0; + pointer-events: none; + text-align: center; + background: #000000b3; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + font-family: + -apple-system, + BlinkMacSystemFont, + Segoe UI, + Helvetica, + Arial, + sans-serif; + transition: opacity 0.75s ease-in-out 1s; + display: flex; + position: absolute; + top: 0; + left: 0; +} +.mapboxgl-scroll-zoom-blocker-show, +.mapboxgl-touch-pan-blocker-show { + opacity: 1; + transition: opacity 0.1s ease-in-out; +} +.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page, +.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page .mapboxgl-canvas { + touch-action: pan-x pan-y; +} +.mapboxgl-ctrl-separator { + background-color: #e0e0e0; + height: 1px; +} +.mapboxgl-ctrl button.mapboxgl-ctrl-level-button { + color: #333; + width: 50px; + height: 50px; + font-size: 18px; + font-weight: 700; +} +.mapboxgl-ctrl button.mapboxgl-ctrl-level-button:first-child { + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} +.mapboxgl-ctrl button.mapboxgl-ctrl-level-button:last-child { + border-bottom-right-radius: 8px; + border-bottom-left-radius: 8px; +} +.mapboxgl-ctrl button.mapboxgl-ctrl-level-button:hover { + background-color: #f5f5f5; +} +.mapboxgl-ctrl button.mapboxgl-ctrl-level-button-selected { + color: #fff; + background-color: #4a5568; +} +.mapboxgl-ctrl button.mapboxgl-ctrl-level-button-selected:hover { + background-color: #2d3748; +} +@font-face { + font-family: swiper-icons; + src: url('data:application/font-woff;charset=utf-8;base64, d09GRgABAAAAAAZgABAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAAGRAAAABoAAAAci6qHkUdERUYAAAWgAAAAIwAAACQAYABXR1BPUwAABhQAAAAuAAAANuAY7+xHU1VCAAAFxAAAAFAAAABm2fPczU9TLzIAAAHcAAAASgAAAGBP9V5RY21hcAAAAkQAAACIAAABYt6F0cBjdnQgAAACzAAAAAQAAAAEABEBRGdhc3AAAAWYAAAACAAAAAj//wADZ2x5ZgAAAywAAADMAAAD2MHtryVoZWFkAAABbAAAADAAAAA2E2+eoWhoZWEAAAGcAAAAHwAAACQC9gDzaG10eAAAAigAAAAZAAAArgJkABFsb2NhAAAC0AAAAFoAAABaFQAUGG1heHAAAAG8AAAAHwAAACAAcABAbmFtZQAAA/gAAAE5AAACXvFdBwlwb3N0AAAFNAAAAGIAAACE5s74hXjaY2BkYGAAYpf5Hu/j+W2+MnAzMYDAzaX6QjD6/4//Bxj5GA8AuRwMYGkAPywL13jaY2BkYGA88P8Agx4j+/8fQDYfA1AEBWgDAIB2BOoAeNpjYGRgYNBh4GdgYgABEMnIABJzYNADCQAACWgAsQB42mNgYfzCOIGBlYGB0YcxjYGBwR1Kf2WQZGhhYGBiYGVmgAFGBiQQkOaawtDAoMBQxXjg/wEGPcYDDA4wNUA2CCgwsAAAO4EL6gAAeNpj2M0gyAACqxgGNWBkZ2D4/wMA+xkDdgAAAHjaY2BgYGaAYBkGRgYQiAHyGMF8FgYHIM3DwMHABGQrMOgyWDLEM1T9/w8UBfEMgLzE////P/5//f/V/xv+r4eaAAeMbAxwIUYmIMHEgKYAYjUcsDAwsLKxc3BycfPw8jEQA/gZBASFhEVExcQlJKWkZWTl5BUUlZRVVNXUNTQZBgMAAMR+E+gAEQFEAAAAKgAqACoANAA+AEgAUgBcAGYAcAB6AIQAjgCYAKIArAC2AMAAygDUAN4A6ADyAPwBBgEQARoBJAEuATgBQgFMAVYBYAFqAXQBfgGIAZIBnAGmAbIBzgHsAAB42u2NMQ6CUAyGW568x9AneYYgm4MJbhKFaExIOAVX8ApewSt4Bic4AfeAid3VOBixDxfPYEza5O+Xfi04YADggiUIULCuEJK8VhO4bSvpdnktHI5QCYtdi2sl8ZnXaHlqUrNKzdKcT8cjlq+rwZSvIVczNiezsfnP/uznmfPFBNODM2K7MTQ45YEAZqGP81AmGGcF3iPqOop0r1SPTaTbVkfUe4HXj97wYE+yNwWYxwWu4v1ugWHgo3S1XdZEVqWM7ET0cfnLGxWfkgR42o2PvWrDMBSFj/IHLaF0zKjRgdiVMwScNRAoWUoH78Y2icB/yIY09An6AH2Bdu/UB+yxopYshQiEvnvu0dURgDt8QeC8PDw7Fpji3fEA4z/PEJ6YOB5hKh4dj3EvXhxPqH/SKUY3rJ7srZ4FZnh1PMAtPhwP6fl2PMJMPDgeQ4rY8YT6Gzao0eAEA409DuggmTnFnOcSCiEiLMgxCiTI6Cq5DZUd3Qmp10vO0LaLTd2cjN4fOumlc7lUYbSQcZFkutRG7g6JKZKy0RmdLY680CDnEJ+UMkpFFe1RN7nxdVpXrC4aTtnaurOnYercZg2YVmLN/d/gczfEimrE/fs/bOuq29Zmn8tloORaXgZgGa78yO9/cnXm2BpaGvq25Dv9S4E9+5SIc9PqupJKhYFSSl47+Qcr1mYNAAAAeNptw0cKwkAAAMDZJA8Q7OUJvkLsPfZ6zFVERPy8qHh2YER+3i/BP83vIBLLySsoKimrqKqpa2hp6+jq6RsYGhmbmJqZSy0sraxtbO3sHRydnEMU4uR6yx7JJXveP7WrDycAAAAAAAH//wACeNpjYGRgYOABYhkgZgJCZgZNBkYGLQZtIJsFLMYAAAw3ALgAeNolizEKgDAQBCchRbC2sFER0YD6qVQiBCv/H9ezGI6Z5XBAw8CBK/m5iQQVauVbXLnOrMZv2oLdKFa8Pjuru2hJzGabmOSLzNMzvutpB3N42mNgZGBg4GKQYzBhYMxJLMlj4GBgAYow/P/PAJJhLM6sSoWKfWCAAwDAjgbRAAB42mNgYGBkAIIbCZo5IPrmUn0hGA0AO8EFTQAA'); + font-weight: 400; + font-style: normal; +} +:root { + --swiper-theme-color: #007aff; +} +:host { + z-index: 1; + margin-left: auto; + margin-right: auto; + display: block; + position: relative; +} +.swiper { + z-index: 1; + margin-left: auto; + margin-right: auto; + padding: 0; + list-style: none; + display: block; + position: relative; + overflow: hidden; +} +.swiper-vertical > .swiper-wrapper { + flex-direction: column; +} +.swiper-wrapper { + z-index: 1; + width: 100%; + height: 100%; + transition-property: transform; + transition-timing-function: var(--swiper-wrapper-transition-timing-function, initial); + box-sizing: content-box; + display: flex; + position: relative; +} +.swiper-android .swiper-slide, +.swiper-ios .swiper-slide, +.swiper-wrapper { + transform: translate(0); +} +.swiper-horizontal { + touch-action: pan-y; +} +.swiper-vertical { + touch-action: pan-x; +} +.swiper-slide { + flex-shrink: 0; + width: 100%; + height: 100%; + transition-property: transform; + display: block; + position: relative; +} +.swiper-slide-invisible-blank { + visibility: hidden; +} +.swiper-autoheight, +.swiper-autoheight .swiper-slide { + height: auto; +} +.swiper-autoheight .swiper-wrapper { + align-items: flex-start; + transition-property: transform, height; +} +.swiper-backface-hidden .swiper-slide { + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + transform: translateZ(0); +} +.swiper-3d.swiper-css-mode .swiper-wrapper { + perspective: 1200px; +} +.swiper-3d .swiper-wrapper { + transform-style: preserve-3d; +} +.swiper-3d { + perspective: 1200px; +} +.swiper-3d .swiper-slide, +.swiper-3d .swiper-cube-shadow { + transform-style: preserve-3d; +} +.swiper-css-mode > .swiper-wrapper { + scrollbar-width: none; + -ms-overflow-style: none; + overflow: auto; +} +.swiper-css-mode > .swiper-wrapper::-webkit-scrollbar { + display: none; +} +.swiper-css-mode > .swiper-wrapper > .swiper-slide { + scroll-snap-align: start start; +} +.swiper-css-mode.swiper-horizontal > .swiper-wrapper { + scroll-snap-type: x mandatory; +} +.swiper-css-mode.swiper-vertical > .swiper-wrapper { + scroll-snap-type: y mandatory; +} +.swiper-css-mode.swiper-free-mode > .swiper-wrapper { + scroll-snap-type: none; +} +.swiper-css-mode.swiper-free-mode > .swiper-wrapper > .swiper-slide { + scroll-snap-align: none; +} +.swiper-css-mode.swiper-centered > .swiper-wrapper:before { + content: ''; + flex-shrink: 0; + order: 9999; +} +.swiper-css-mode.swiper-centered > .swiper-wrapper > .swiper-slide { + scroll-snap-align: center center; + scroll-snap-stop: always; +} +.swiper-css-mode.swiper-centered.swiper-horizontal > .swiper-wrapper > .swiper-slide:first-child:dir(ltr) { + margin-left: var(--swiper-centered-offset-before); +} +.swiper-css-mode.swiper-centered.swiper-horizontal > .swiper-wrapper > .swiper-slide:first-child:dir(rtl) { + margin-right: var(--swiper-centered-offset-before); +} +.swiper-css-mode.swiper-centered.swiper-horizontal > .swiper-wrapper:before { + height: 100%; + min-height: 1px; + width: var(--swiper-centered-offset-after); +} +.swiper-css-mode.swiper-centered.swiper-vertical > .swiper-wrapper > .swiper-slide:first-child { + margin-top: var(--swiper-centered-offset-before); +} +.swiper-css-mode.swiper-centered.swiper-vertical > .swiper-wrapper:before { + width: 100%; + min-width: 1px; + height: var(--swiper-centered-offset-after); +} +.swiper-3d .swiper-slide-shadow, +.swiper-3d .swiper-slide-shadow-left, +.swiper-3d .swiper-slide-shadow-right, +.swiper-3d .swiper-slide-shadow-top, +.swiper-3d .swiper-slide-shadow-bottom, +.swiper-3d .swiper-slide-shadow, +.swiper-3d .swiper-slide-shadow-left, +.swiper-3d .swiper-slide-shadow-right, +.swiper-3d .swiper-slide-shadow-top, +.swiper-3d .swiper-slide-shadow-bottom { + pointer-events: none; + z-index: 10; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; +} +.swiper-3d .swiper-slide-shadow { + background: #00000026; +} +.swiper-3d .swiper-slide-shadow-left { + background-image: linear-gradient(270deg, #00000080, #0000); +} +.swiper-3d .swiper-slide-shadow-right { + background-image: linear-gradient(90deg, #00000080, #0000); +} +.swiper-3d .swiper-slide-shadow-top { + background-image: linear-gradient(#0000, #00000080); +} +.swiper-3d .swiper-slide-shadow-bottom { + background-image: linear-gradient(#00000080, #0000); +} +.swiper-lazy-preloader { + z-index: 10; + transform-origin: 50%; + box-sizing: border-box; + border: 4px solid var(--swiper-preloader-color, var(--swiper-theme-color)); + border-top-color: #0000; + border-radius: 50%; + width: 42px; + height: 42px; + margin-top: -21px; + margin-left: -21px; + position: absolute; + top: 50%; + left: 50%; +} +.swiper:not(.swiper-watch-progress) .swiper-lazy-preloader, +.swiper-watch-progress .swiper-slide-visible .swiper-lazy-preloader { + animation: 1s linear infinite swiper-preloader-spin; +} +.swiper-lazy-preloader-white { + --swiper-preloader-color: #fff; +} +.swiper-lazy-preloader-black { + --swiper-preloader-color: #000; +} +@keyframes swiper-preloader-spin { + 0% { + transform: rotate(0); + } + to { + transform: rotate(360deg); + } +} +@keyframes bgaZlG_businessTooltipIn { + 0% { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes bgaZlG_businessTooltipOut { + 0% { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.9); + } +} +.bgaZlG_tooltipContent { + transform-origin: var(--radix-tooltip-content-transform-origin); +} +.bgaZlG_tooltipOpen { + animation: 0.18s ease-out both bgaZlG_businessTooltipIn; +} +.bgaZlG_tooltipClosing { + pointer-events: none; + animation: 0.14s ease-in both bgaZlG_businessTooltipOut; +} +.mapboxgl-ctrl-logo { + display: none !important; +} +.mapboxgl-ctrl-group { + background-clip: padding-box !important; + border: 1px solid #0d0d0d1a !important; + border-radius: 20px !important; + box-shadow: 0 1px 2px #00000026 !important; +} +.dark .mapboxgl-ctrl-group { + opacity: 0.9 !important; + background-color: #000 !important; +} +.mapboxgl-ctrl-group button { + width: 36px !important; + height: 36px !important; +} +.mapboxgl-ctrl-group button + button { + border-top-color: #0d0d0d0d !important; +} +.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon { + background-image: url(data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyMCIKICBoZWlnaHQ9IjIwIgogIHZpZXdCb3g9IjAgMCAyMCAyMCIKICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgo+CiAgPHBhdGgKICAgIGQ9Ik05LjMzNDk2IDE2LjVWMTAuNjY1SDMuNUMzLjEzMjczIDEwLjY2NSAyLjgzNDk2IDEwLjM2NzMgMi44MzQ5NiAxMEMyLjgzNDk2IDkuNjMyNzMgMy4xMzI3MyA5LjMzNDk2IDMuNSA5LjMzNDk2SDkuMzM0OTZWMy41QzkuMzM0OTYgMy4xMzI3MyA5LjYzMjczIDIuODM0OTYgMTAgMi44MzQ5NkMxMC4zNjczIDIuODM0OTYgMTAuNjY1IDMuMTMyNzMgMTAuNjY1IDMuNVY5LjMzNDk2SDE2LjVMMTYuNjMzOCA5LjM0ODYzQzE2LjkzNjkgOS40MTA1NyAxNy4xNjUgOS42Nzg1NyAxNy4xNjUgMTBDMTcuMTY1IDEwLjMyMTQgMTYuOTM2OSAxMC41ODk0IDE2LjYzMzggMTAuNjUxNEwxNi41IDEwLjY2NUgxMC42NjVWMTYuNUMxMC42NjUgMTYuODY3MyAxMC4zNjczIDE3LjE2NSAxMCAxNy4xNjVDOS42MzI3MyAxNy4xNjUgOS4zMzQ5NiAxNi44NjczIDkuMzM0OTYgMTYuNVoiCiAgLz4KPC9zdmc+Cg==) !important; + background-size: 18px 18px !important; +} +.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon { + background-image: url(data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyMCIKICBoZWlnaHQ9IjIwIgogIHZpZXdCb3g9IjAgMCAyMCAyMCIKICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgo+CiAgPHBhdGgKICAgIGQ9Ik0xNi41IDkuMzM0OTZMMTYuNjMzOCA5LjM0ODYzQzE2LjkzNjkgOS40MTA1NyAxNy4xNjUgOS42Nzg1NyAxNy4xNjUgMTBDMTcuMTY1IDEwLjMyMTQgMTYuOTM2OSAxMC41ODk0IDE2LjYzMzggMTAuNjUxNEwxNi41IDEwLjY2NUgzLjVDMy4xMzI3MyAxMC42NjUgMi44MzQ5NiAxMC4zNjczIDIuODM0OTYgMTBDMi44MzQ5NiA5LjYzMjczIDMuMTMyNzMgOS4zMzQ5NiAzLjUgOS4zMzQ5NkgxNi41WiIKICAvPgo8L3N2Zz4K) !important; + background-size: 18px 18px !important; +} +.dark .mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon, +.dark .mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon { + filter: invert() !important; +} +.mapboxgl-scroll-zoom-blocker { + background-color: #0d0d0d !important; + border-radius: 9999px !important; + width: auto !important; + height: auto !important; + padding: 6px 16px !important; + font-size: 13px !important; + font-weight: 500 !important; + top: auto !important; + bottom: 10px !important; + left: auto !important; + right: 10px !important; +} +.BqefNq_userMarkerPulse { + will-change: transform; + animation: 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite BqefNq_userMarkerPulse; +} +@keyframes BqefNq_userMarkerPulse { + 0%, + to { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} +.SceYza_memoryFootnoteGlowIn { + animation: 0.22s ease-out SceYza_memoryFootnoteGlowIn; +} +.SceYza_memoryFootnoteShimmerOnce { + animation-iteration-count: 1 !important; +} +@keyframes SceYza_memoryFootnoteGlowIn { + 0% { + opacity: 0.75; + transform: translateY(1px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +.active-view-transition.open-thread-sidebar, +.active-view-transition.close-thread-sidebar { + --vt_model_picker: model-picker; + --vt_share_chat_wide_button: share-chat-wide-button; + --vt_share_chat_compact_button: share-chat-compact-button; + --vt_thread_tools: thread-tools; + --vt-thread-header-open-canvas: open-canvas-button; + --thread-extended-info-transition-name: thread-extended-info; + --vt-disable-screen-column-transition: none; + --vt_toggle_sidebar_opened: toggle-sidebar-icon-opened; + --vt_toggle_sidebar_closed: toggle-sidebar-icon-closed; + --vt-composer-speech-button: composer-speech-button; + --vt_new_chat_thread: new-chat-thread; + --vt-profile-avatar-thread: profile-avatar-active; +} +@media (prefers-reduced-motion: reduce) { + :is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition { + display: none; + } +} +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-group( + * +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-old(*), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-new(*) { + animation-duration: var(--vt-duration, 0.3s); + animation-timing-function: var(--vt-timing-function, var(--spring-common)); +} +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-old( + IGEM1a_model-picker +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-old( + IGEM1a_toggle-sidebar-icon +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-old( + IGEM1a_share-chat-wide-button +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-old( + IGEM1a_share-chat-compact-button +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-old( + IGEM1a_thread-tools +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-old( + IGEM1a_open-canvas-button +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-old( + IGEM1a_composer-speech-button +) { + display: none; +} +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-new( + IGEM1a_model-picker +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-old( + IGEM1a_toggle-sidebar-icon +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-new( + IGEM1a_share-chat-wide-button +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-new( + IGEM1a_share-chat-compact-button +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-new( + IGEM1a_thread-tools +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-new( + IGEM1a_open-canvas-button +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-new( + IGEM1a_composer-speech-button +) { + height: 100%; + animation: none; +} +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-group( + IGEM1a_profile-avatar-active +) { + z-index: 2; + animation: none; +} +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-new( + IGEM1a_profile-avatar-active +) { + animation: none; +} +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-old( + IGEM1a_thread-extended-info +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-new( + IGEM1a_thread-extended-info +) { + object-fit: none; + height: 100%; + overflow: clip; +} +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-old( + IGEM1a_thread +), +:is(.active-view-transition.open-thread-sidebar, .active-view-transition.close-thread-sidebar)::view-transition-new( + IGEM1a_thread +) { + object-fit: none; + height: 100%; + overflow: clip; +} +.active-view-transition.open-thread-sidebar::view-transition-old(IGEM1a_thread-extended-info) { + display: none; +} +.active-view-transition.close-thread-sidebar::view-transition-new(IGEM1a_thread-extended-info) { + display: none; +} +@keyframes BZ_Pyq_fade-in { + to { + opacity: 1; + } +} +.BZ_Pyq_root .BZ_Pyq_fadeIn, +.BZ_Pyq_root hr, +.BZ_Pyq_root li, +.BZ_Pyq_root tr, +.BZ_Pyq_root blockquote, +.BZ_Pyq_root code, +.BZ_Pyq_root pre { + opacity: 0; + animation: BZ_Pyq_fade-in var(--duration, 0.7s) cubic-bezier(0.37, 0.55, 0.86, 0.88) forwards; +} +@media (prefers-reduced-motion: reduce) { + .BZ_Pyq_root .BZ_Pyq_fadeIn, + .BZ_Pyq_root hr, + .BZ_Pyq_root li, + .BZ_Pyq_root tr, + .BZ_Pyq_root blockquote, + .BZ_Pyq_root code, + .BZ_Pyq_root pre { + --duration: 0s; + opacity: 1; + } +} +@keyframes QKycbG_fade { + 0% { + opacity: 0; + } + to { + opacity: 1; + } +} +.QKycbG_markdown.markdown .katex-error { + display: none; +} +.QKycbG_markdown.markdown .katex-display { + opacity: 0; + animation: 0.4s 50ms forwards QKycbG_fade; +} +.QKycbG_markdown.markdown p { + margin-bottom: 0 !important; +} +@layer properties { + @supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or + ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { + *, + :before, + :after, + ::backdrop { + --tw-outline-style: solid; + --tw-border-style: solid; + --tw-shadow: 0 0 transparent; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 transparent; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 transparent; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 transparent; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 transparent; + --tw-leading: initial; + --tw-content: ''; + } + } +} +.wcDTda_prosemirror-parent .ProseMirror[contenteditable] { + outline-style: var(--tw-outline-style); + --tw-outline-style: none; + outline-width: 0; + outline-style: none; +} +.wcDTda_fallbackTextarea, +.wcDTda_prosemirror-parent .ProseMirror { + margin-top: calc(var(--spacing, 0.25rem) * 4); + margin-bottom: calc(var(--spacing, 0.25rem) * 0); + padding-inline: calc(var(--spacing, 0.25rem) * 0); + padding-top: calc(var(--spacing, 0.25rem) * 0); + padding-bottom: calc(var(--spacing, 0.25rem) * 4); + word-wrap: break-word; + white-space: pre-wrap; + white-space: break-spaces; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; + font-feature-settings: 'liga' 0; + transform: translateY(-0.5px); +} +.wcDTda_fallbackTextarea { + box-sizing: content-box; + height: calc(var(--spacing, 0.25rem) * 10); + resize: none; + border-style: var(--tw-border-style); + width: 100%; + padding-inline: calc(var(--spacing, 0.25rem) * 0); + color: var(--text-primary); + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) + var(--tw-ring-color, currentcolor); + box-shadow: + var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), + var(--tw-shadow); + background-color: #0000; + border-width: 0; + display: block; +} +.wcDTda_fallbackTextarea::placeholder { + color: var(--text-tertiary); +} +.wcDTda_fallbackTextarea:dir(ltr)::placeholder { + padding-left: 1px; +} +.wcDTda_fallbackTextarea:dir(rtl)::placeholder { + padding-right: 1px; +} +.wcDTda_fallbackTextarea { + height: 1lh; +} +@container wcDTda_thread (width>=640px) { + :is( + .wcDTda_prosemirror-parent[data-size='lg'] .ProseMirror, + .wcDTda_prosemirror-parent[data-size='lg'] .wcDTda_fallbackTextarea + ) { + margin-top: calc(var(--spacing, 0.25rem) * 3.5); + padding-bottom: calc(var(--spacing, 0.25rem) * 3.5); + font-size: var(--text-lg, 1.125rem); + line-height: var(--tw-leading, var(--text-lg--line-height, calc(1.75 / 1.125))); + white-space: pre-wrap; + } + :is( + .wcDTda_prosemirror-parent[data-size='xl'] .ProseMirror, + .wcDTda_prosemirror-parent[data-size='xl'] .wcDTda_fallbackTextarea + ) { + margin-top: calc(var(--spacing, 0.25rem) * 3.5); + padding-bottom: calc(var(--spacing, 0.25rem) * 3.5); + font-size: var(--text-xl, 1.25rem); + line-height: var(--tw-leading, var(--text-xl--line-height, calc(1.75 / 1.25))); + white-space: pre-wrap; + } +} +.wcDTda_prosemirror-parent.ProseMirror br { + --tw-leading: normal; + line-height: normal; +} +.wcDTda_prosemirror-parent.default-browser .placeholder:after { + pointer-events: none; + cursor: text; + color: var(--text-tertiary); + --tw-content: attr(data-placeholder); + content: var(--tw-content); + padding-left: 1px; + position: relative; +} +.wcDTda_prosemirror-parent.firefox .placeholder:before { + pointer-events: none; + cursor: text; + color: var(--text-tertiary); + --tw-content: attr(data-placeholder); + content: var(--tw-content); + padding-left: 1px; + position: absolute; +} +.wcDTda_prosemirror-parent.default-browser .placeholder .ProseMirror-trailingBreak { + display: none !important; +} +.wcDTda_prosemirror-parent p { + white-space: pre-wrap; +} +.wcDTda_prosemirror-parent p.placeholder { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} +.screen-arch .wcDTda_prosemirror-parent p.placeholder { + width: -webkit-fit-content; + width: fit-content; + view-transition-name: var(--vt-composer-placeholder); +} +.wcDTda_prosemirror-parent .ProseMirror-separator { + display: none !important; +} +.wcDTda_prosemirror-parent .pm-bracket-tag { + cursor: text; + border-radius: var(--radius-md, 0.375rem); + text-overflow: ellipsis; + white-space: nowrap; + color: #2f7cf5; + background-color: #2f7cf51a; + background-color: lab(52.5277% 10.4527 -68.6236/0.1); + align-items: center; + max-width: 16rem; + display: inline-flex; + overflow: hidden; +} +@media (hover: hover) { + .wcDTda_prosemirror-parent .pm-bracket-tag:hover { + color: #0285ff; + background-color: #e5f3ff; + } +} +.wcDTda_prosemirror-parent .pm-bracket-tag { + padding: 3px 6px; +} +.wcDTda_prosemirror-parent .pm-bracket-tag[data-template-active='true'] { + box-shadow: 0 0 0 1px var(--interactive-border-focus); + background-color: var(--interactive-bg-accent-muted-hover); +} +@property --tw-outline-style { + syntax: '*'; + inherits: false; + initial-value: solid; +} +@property --tw-border-style { + syntax: '*'; + inherits: false; + initial-value: solid; +} +@property --tw-shadow { + syntax: '*'; + inherits: false; + initial-value: 0 0 transparent; +} +@property --tw-shadow-color { + syntax: '*'; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ''; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: '*'; + inherits: false; + initial-value: 0 0 transparent; +} +@property --tw-inset-shadow-color { + syntax: '*'; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ''; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: '*'; + inherits: false; +} +@property --tw-ring-shadow { + syntax: '*'; + inherits: false; + initial-value: 0 0 transparent; +} +@property --tw-inset-ring-color { + syntax: '*'; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: '*'; + inherits: false; + initial-value: 0 0 transparent; +} +@property --tw-ring-inset { + syntax: '*'; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ''; + inherits: false; + initial-value: 0; +} +@property --tw-ring-offset-color { + syntax: '*'; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: '*'; + inherits: false; + initial-value: 0 0 transparent; +} +@property --tw-leading { + syntax: '*'; + inherits: false; +} +@property --tw-content { + syntax: '*'; + inherits: false; + initial-value: ''; +} +.R6Vx5W_threadRoot { + --thread-safe-area-height: calc(100lvh - var(--thread-safe-area-inset-top) - var(--thread-safe-area-inset-bottom)); + --thread-safe-area-inset-top: calc(var(--header-height) + env(safe-area-inset-top, 0px)); + --thread-safe-area-inset-bottom: calc( + var(--thread-footer-height, 150px) + var(--screen-keyboard-height, 0px) + env(safe-area-inset-bottom, 0px) + ); +} +.R6Vx5W_threadGutter { + --thread-end-gutter-active-height: calc( + var(--thread-safe-area-height) - var(--thread-stream-context-height) - 2 * var(--thread-turn-vertical-padding) + ); + --thread-stream-context-height: max( + 2.75rem + 2 * var(--thread-turn-vertical-padding), + 1/3 * var(--thread-safe-area-height) + ); + --thread-turn-vertical-padding: 1.25rem; +} +._69lA8a_main .prose { + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + display: -webkit-box; + overflow: hidden; +} +@supports selector(::scroll-button(*)) { + @media (pointer: fine) { + .xuHWuq_carousel { + scrollbar-width: none; + scroll-padding-inline: calc(9 * var(--spacing)); + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + } + @media (prefers-reduced-motion: reduce) { + .xuHWuq_carousel { + scroll-behavior: auto; + } + } + .xuHWuq_carousel::scroll-button(*) { + aspect-ratio: 1; + background-color: var(--main-surface-primary); + border: 1px solid var(--border-medium); + color: var(--text-primary); + width: calc(9 * var(--spacing)); + cursor: pointer; + border-radius: 50%; + justify-content: center; + align-items: center; + line-height: 0; + transition: opacity 0.15s ease-in-out; + display: flex; + position: absolute; + top: 50%; + } + @starting-style { + .xuHWuq_carousel::scroll-button(*) { + opacity: 0; + } + } + .xuHWuq_carousel::scroll-button(*):hover { + background-color: var(--main-surface-secondary); + } + .xuHWuq_carousel::scroll-button(*):disabled { + opacity: 0; + pointer-events: none; + } + .xuHWuq_carousel::scroll-button(left) { + content: url('data:image/svg+xml;utf8,'); + left: 0; + transform: translate(-50%, -50%); + } + @container (width<43rem) { + .xuHWuq_carousel::scroll-button(left) { + transform: translate(calc(-1 * (var(--thread-content-margin) - 2 * var(--spacing))), -50%); + } + } + .dark .xuHWuq_carousel::scroll-button(left) { + content: url('data:image/svg+xml;utf8,'); + } + .xuHWuq_carousel::scroll-button(right) { + content: url('data:image/svg+xml;utf8,'); + right: 0; + transform: translate(50%, -50%); + } + @container (width<43rem) { + .xuHWuq_carousel::scroll-button(right) { + transform: translate(calc(var(--thread-content-margin) - 2 * var(--spacing)), -50%); + } + } + .dark .xuHWuq_carousel::scroll-button(right) { + content: url('data:image/svg+xml;utf8,'); + } + } +} +@keyframes sPZ93q_add-top-shadow { + 0% { + box-shadow: 0 1px #0000; + } + .1%, + to { + box-shadow: 0 1px 0 var(--border-sharp); + } +} +@keyframes sPZ93q_add-bottom-shadow { + 0%, + 99.9% { + box-shadow: 0 -1px 0 var(--border-sharp); + } + to { + box-shadow: 0 -1px #0000; + } +} +.sPZ93q_leadingBar { + animation: linear both sPZ93q_add-top-shadow; + box-shadow: 0 1px #0000; +} +.sPZ93q_leadingBarScrollAnimation { + animation-timeline: scroll(); +} +.sPZ93q_trailingBar { + animation: linear both sPZ93q_add-bottom-shadow; + box-shadow: 0 -1px #0000; +} +.sPZ93q_trailingBarScrollAnimation { + animation-timeline: scroll(); +} +.sPZ93q_primary { + background-color: var(--bar-background-color, var(--main-surface-primary)); +} +._56rfYG_screen { + display: var(--screen-display, grid); + grid-template: '_56rfYG_leading' max-content '_56rfYG_content' 1fr '_56rfYG_trailing' max-content '_56rfYG_keyboard' / minmax( + 0, + 1fr + ); +} +@supports not (overflow: clip) { + ._56rfYG_screen { + overflow: var(--screen-overflow, hidden auto); + } +} +@supports (overflow: clip) { + ._56rfYG_screen { + overflow: var(--screen-overflow, clip auto); + } +} +._56rfYG_screen { + scrollbar-gutter: var(--screen-scrollbar-gutter-override, stable); + padding-top: calc(var(--screen-anchor-top) + var(--screen-top-offset, 0px)); + width: 100%; +} +._56rfYG_screen [slot='content'] { + padding-inline: var(--screen-content-inline-padding, var(--screen-inline-padding)); + position: var(--screen-content-position, relative); + grid-area: _56rfYG_content; +} +._56rfYG_screen [slot='leading'] { + min-width: var(--screen-leading-slot-min-width); + overflow: var(--screen-leading-slot-overflow); + top: var(--screen-leading-slot-top, 0); + z-index: var(--screen-leading-slot-z-index, 20); + grid-area: _56rfYG_leading; + position: -webkit-sticky; + position: sticky; +} +._56rfYG_screen [slot='trailing'] { + bottom: var(--keyboard-safe-area-bottom, 0); + padding-inline: var(--screen-trailing-inline-padding, var(--screen-inline-padding)); + z-index: var(--screen-leading-slot-z-index, 20); + grid-area: _56rfYG_trailing; + position: -webkit-sticky; + position: sticky; +} +._56rfYG_screen [slot='keyboard'] { + height: var(--keyboard-safe-area-bottom, 0px); + background: #fcfcfc; + grid-area: _56rfYG_keyboard; + position: -webkit-sticky; + position: sticky; + bottom: 0; +} +._56rfYG_screen:where([screen-anchor='vertical'], [screen-anchor='top']) { + --safe-area-top: calc(env(_56rfYG_titlebar-area-y, 0px) + env(safe-area-inset-top, 0px)); + --screen-anchor-top: var(--safe-area-top); +} +._56rfYG_screen:where([screen-anchor='vertical'], [screen-anchor='bottom']) { + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --keyboard-safe-area-bottom: max(var(--screen-keyboard-height), env(_56rfYG_keyboard-inset-height, 0px)); + --screen-anchor-bottom: var(--safe-area-bottom); +} diff --git a/จัดการตำแหน่ง Node n8n_files/cot-message-lq73ofyy.css b/จัดการตำแหน่ง Node n8n_files/cot-message-lq73ofyy.css index f54cb03..2c5afdb 100644 --- a/จัดการตำแหน่ง Node n8n_files/cot-message-lq73ofyy.css +++ b/จัดการตำแหน่ง Node n8n_files/cot-message-lq73ofyy.css @@ -1 +1,102 @@ -@keyframes fade-in{0%{opacity:0}to{opacity:1}}.teleprompter{position:relative;overflow:visible}.teleprompter-inner{flex-wrap:wrap;align-content:flex-start;display:flex;position:absolute;top:0;bottom:0;left:0;right:0}.teleprompter-word{opacity:0;white-space:pre;animation:fade-in var(--fade,.5s) cubic-bezier(.37, .55, .86, .88) forwards}@keyframes dot-fade{0%,to{opacity:.5}50%{opacity:1}}.animate-dot-fade{animation:1s ease-in-out infinite dot-fade}.gxcrJW_frostedContainer{width:100%;position:relative;overflow:hidden}.gxcrJW_blurStack{pointer-events:none;z-index:5;height:5rem;position:absolute;top:0;bottom:0;left:0;right:0}.gxcrJW_blur{--frost:color-mix(in srgb, var(--bg-tertiary) 60%, transparent);background:linear-gradient(to bottom, var(--frost) 0%, transparent var(--gradient-end,60%));height:100%;-webkit-backdrop-filter:blur(var(--blur,0px));opacity:var(--alpha,.4);will-change:backdrop-filter, opacity;border-top-left-radius:8px;border-top-right-radius:8px;position:absolute;top:0;bottom:auto;left:0;right:0;transform:translateZ(0);-webkit-mask-image:linear-gradient(#000 60%,#0000 100%);mask-image:linear-gradient(#000 60%,#0000 100%)}.gxcrJW_layer0{--blur:.5px;--alpha:.65;--gradient-end:25%}.gxcrJW_layer1{--blur:2px;--alpha:.5;--gradient-end:35%}.gxcrJW_layer2{--blur:6px;--alpha:.38;--gradient-end:50%}.gxcrJW_layer3{--blur:16px;--alpha:.28;--gradient-end:65%}.gxcrJW_layer4{--blur:32px;--alpha:.18;--gradient-end:80%}.gxcrJW_layer5{--blur:64px;--alpha:.1;--gradient-end:95%} +@keyframes fade-in { + 0% { + opacity: 0; + } + to { + opacity: 1; + } +} +.teleprompter { + position: relative; + overflow: visible; +} +.teleprompter-inner { + flex-wrap: wrap; + align-content: flex-start; + display: flex; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; +} +.teleprompter-word { + opacity: 0; + white-space: pre; + animation: fade-in var(--fade, 0.5s) cubic-bezier(0.37, 0.55, 0.86, 0.88) forwards; +} +@keyframes dot-fade { + 0%, + to { + opacity: 0.5; + } + 50% { + opacity: 1; + } +} +.animate-dot-fade { + animation: 1s ease-in-out infinite dot-fade; +} +.gxcrJW_frostedContainer { + width: 100%; + position: relative; + overflow: hidden; +} +.gxcrJW_blurStack { + pointer-events: none; + z-index: 5; + height: 5rem; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; +} +.gxcrJW_blur { + --frost: color-mix(in srgb, var(--bg-tertiary) 60%, transparent); + background: linear-gradient(to bottom, var(--frost) 0%, transparent var(--gradient-end, 60%)); + height: 100%; + -webkit-backdrop-filter: blur(var(--blur, 0px)); + opacity: var(--alpha, 0.4); + will-change: backdrop-filter, opacity; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + position: absolute; + top: 0; + bottom: auto; + left: 0; + right: 0; + transform: translateZ(0); + -webkit-mask-image: linear-gradient(#000 60%, #0000 100%); + mask-image: linear-gradient(#000 60%, #0000 100%); +} +.gxcrJW_layer0 { + --blur: 0.5px; + --alpha: 0.65; + --gradient-end: 25%; +} +.gxcrJW_layer1 { + --blur: 2px; + --alpha: 0.5; + --gradient-end: 35%; +} +.gxcrJW_layer2 { + --blur: 6px; + --alpha: 0.38; + --gradient-end: 50%; +} +.gxcrJW_layer3 { + --blur: 16px; + --alpha: 0.28; + --gradient-end: 65%; +} +.gxcrJW_layer4 { + --blur: 32px; + --alpha: 0.18; + --gradient-end: 80%; +} +.gxcrJW_layer5 { + --blur: 64px; + --alpha: 0.1; + --gradient-end: 95%; +} diff --git a/จัดการตำแหน่ง Node n8n_files/index-ivzj8m6u.css b/จัดการตำแหน่ง Node n8n_files/index-ivzj8m6u.css index 5ec5ad8..cf8ba30 100644 --- a/จัดการตำแหน่ง Node n8n_files/index-ivzj8m6u.css +++ b/จัดการตำแหน่ง Node n8n_files/index-ivzj8m6u.css @@ -1 +1,9 @@ -.T40Hfq_modelCursor{width:0;height:0;display:inline-block;position:relative}.T40Hfq_modelCursor:after{content:none} +.T40Hfq_modelCursor { + width: 0; + height: 0; + display: inline-block; + position: relative; +} +.T40Hfq_modelCursor:after { + content: none; +} diff --git a/จัดการตำแหน่ง Node n8n_files/product-variants-ga945uk2.css b/จัดการตำแหน่ง Node n8n_files/product-variants-ga945uk2.css index dfec2c2..6d83690 100644 --- a/จัดการตำแหน่ง Node n8n_files/product-variants-ga945uk2.css +++ b/จัดการตำแหน่ง Node n8n_files/product-variants-ga945uk2.css @@ -1 +1,30 @@ -@keyframes YfNCsW_fade{to{opacity:1}}.YfNCsW_fadeIn{opacity:0;animation:YfNCsW_fade var(--duration,0s) cubic-bezier(.37, .55, .86, .88) forwards var(--delay,0s);animation-iteration-count:1}@media (prefers-reduced-motion:reduce){.YfNCsW_fadeIn{--duration:0s;opacity:1}}.YfNCsW_marker.YfNCsW_hidden{display:none}.YfNCsW_marker.YfNCsW_animate{opacity:0;animation:YfNCsW_fade var(--duration,0s) cubic-bezier(.37, .55, .86, .88) forwards var(--delay,0s);animation-iteration-count:1}@media (prefers-reduced-motion:reduce){.YfNCsW_marker.YfNCsW_animate{--duration:0s;opacity:1}} +@keyframes YfNCsW_fade { + to { + opacity: 1; + } +} +.YfNCsW_fadeIn { + opacity: 0; + animation: YfNCsW_fade var(--duration, 0s) cubic-bezier(0.37, 0.55, 0.86, 0.88) forwards var(--delay, 0s); + animation-iteration-count: 1; +} +@media (prefers-reduced-motion: reduce) { + .YfNCsW_fadeIn { + --duration: 0s; + opacity: 1; + } +} +.YfNCsW_marker.YfNCsW_hidden { + display: none; +} +.YfNCsW_marker.YfNCsW_animate { + opacity: 0; + animation: YfNCsW_fade var(--duration, 0s) cubic-bezier(0.37, 0.55, 0.86, 0.88) forwards var(--delay, 0s); + animation-iteration-count: 1; +} +@media (prefers-reduced-motion: reduce) { + .YfNCsW_marker.YfNCsW_animate { + --duration: 0s; + opacity: 1; + } +} diff --git a/จัดการตำแหน่ง Node n8n_files/proxy.html b/จัดการตำแหน่ง Node n8n_files/proxy.html index 5efd59f..ed7b223 100644 --- a/จัดการตำแหน่ง Node n8n_files/proxy.html +++ b/จัดการตำแหน่ง Node n8n_files/proxy.html @@ -1,16 +1,17 @@ - + - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + diff --git a/จัดการตำแหน่ง Node n8n_files/root-chahl8g3.css b/จัดการตำแหน่ง Node n8n_files/root-chahl8g3.css index 93cbdff..5614dd8 100644 --- a/จัดการตำแหน่ง Node n8n_files/root-chahl8g3.css +++ b/จัดการตำแหน่ง Node n8n_files/root-chahl8g3.css @@ -1,2 +1,46034 @@ /*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-font-weight:initial;--tw-outline-style:solid;--tw-leading:initial;--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:transparent;--tw-gradient-via:transparent;--tw-gradient-to:transparent;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 transparent;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 transparent;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 transparent;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 transparent;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 transparent;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-contain-size:initial;--tw-contain-layout:initial;--tw-contain-paint:initial;--tw-contain-style:initial;--tw-content:"";--tw-mask-linear:linear-gradient(#fff,#fff);--tw-mask-radial:linear-gradient(#fff,#fff);--tw-mask-conic:linear-gradient(#fff,#fff);--tw-mask-left:linear-gradient(#fff,#fff);--tw-mask-right:linear-gradient(#fff,#fff);--tw-mask-bottom:linear-gradient(#fff,#fff);--tw-mask-top:linear-gradient(#fff,#fff);--tw-mask-bottom-from-position:0%;--tw-mask-bottom-to-position:100%;--tw-mask-bottom-from-color:black;--tw-mask-bottom-to-color:transparent;--tw-mask-right-from-position:0%;--tw-mask-right-to-position:100%;--tw-mask-right-from-color:black;--tw-mask-right-to-color:transparent;--tw-mask-left-from-position:0%;--tw-mask-left-to-position:100%;--tw-mask-left-from-color:black;--tw-mask-left-to-color:transparent;--mask-shimmer-offset:0%;--tw-mask-shimmer-duration:4s;--tw-mask-shimmer-delay:0s}}}.composer-parent{--composer-footer_height:var(--composer-bar_footer-current-height,32px);--composer-bar_height:var(--composer-bar_current-height,52px);--composer-bar_width:var(--composer-bar_current-width,768px);--mask-fill:linear-gradient(to bottom,white 0%,white 100%);--mask-erase:linear-gradient(to bottom,black 0%,black 100%)}.masked-content{--content-gradient:linear-gradient(0deg,#d9d9d9 0%,#d8d8d8fc 8.07%,#d7d7d7fa 15.54%,#d4d4d4f2 22.5%,#d0d0d0eb 29.04%,#ccccccde 35.26%,#c6c6c6d1 41.25%,#c0c0c0bf 47.1%,#b8b8b8ad 52.9%,#b0b0b099 58.75%,#a8a8a885 64.74%,#9e9e9e6b 70.96%,#94949454 77.5%,#8a8a8a38 84.46%,#7f7f7f1c 91.93%,#73737300 100%);--composer-bar_safe-margins:20px;-webkit-mask-image:var(--mask-fill),var(--content-gradient),var(--composer-bar_skeleton);-webkit-mask-image:var(--mask-fill),var(--content-gradient),var(--composer-bar_skeleton);-webkit-mask-image:var(--mask-fill),var(--content-gradient),var(--composer-bar_skeleton);-webkit-mask-image:var(--mask-fill),var(--content-gradient),var(--composer-bar_skeleton);mask-image:var(--mask-fill),var(--content-gradient),var(--composer-bar_skeleton);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:auto,calc(100% - var(--composer-bar_safe-margins))calc(var(--composer-bar_height) + var(--composer-bar_mask-grace-area)),var(--composer-bar_width)var(--composer-bar_height);-webkit-mask-size:auto,calc(100% - var(--composer-bar_safe-margins))calc(var(--composer-bar_height) + var(--composer-bar_mask-grace-area)),var(--composer-bar_width)var(--composer-bar_height);-webkit-mask-size:auto,calc(100% - var(--composer-bar_safe-margins))calc(var(--composer-bar_height) + var(--composer-bar_mask-grace-area)),var(--composer-bar_width)var(--composer-bar_height);-webkit-mask-size:auto,calc(100% - var(--composer-bar_safe-margins))calc(var(--composer-bar_height) + var(--composer-bar_mask-grace-area)),var(--composer-bar_width)var(--composer-bar_height);mask-size:auto,calc(100% - var(--composer-bar_safe-margins))calc(var(--composer-bar_height) + var(--composer-bar_mask-grace-area)),var(--composer-bar_width)var(--composer-bar_height);-webkit-mask-position:top center,center calc(100% - var(--composer-footer_height)),center calc(100% - var(--composer-footer_height));-webkit-mask-position:top center,center calc(100% - var(--composer-footer_height)),center calc(100% - var(--composer-footer_height));-webkit-mask-position:top center,center calc(100% - var(--composer-footer_height)),center calc(100% - var(--composer-footer_height));-webkit-mask-position:top center,center calc(100% - var(--composer-footer_height)),center calc(100% - var(--composer-footer_height));mask-position:top center,center calc(100% - var(--composer-footer_height)),center calc(100% - var(--composer-footer_height));-webkit-mask-composite:source-out;-webkit-mask-source-type:luminance;-webkit-mask-composite:source-out;-webkit-mask-source-type:luminance;-webkit-mask-composite:source-out;-webkit-mask-source-type:luminance;-webkit-mask-composite:source-out;mask-composite:subtract;-webkit-mask-source-type:luminance;mask-mode:luminance}@supports (color:color(display-p3 0 0 0)){.masked-content{--content-gradient:linear-gradient(0deg,color(display-p3 .851 .851 .851) 0%,color(display-p3 .8488 .8488 .8488/.99) 8.07%,color(display-p3 .8423 .8423 .8423/.98) 15.54%,color(display-p3 .8317 .8317 .8317/.95) 22.5%,color(display-p3 .8171 .8171 .8171/.92) 29.04%,color(display-p3 .7988 .7988 .7988/.87) 35.26%,color(display-p3 .777 .777 .777/.82) 41.25%,color(display-p3 .7518 .7518 .7518/.75) 47.1%,color(display-p3 .7234 .7234 .7234/.68) 52.9%,color(display-p3 .692 .692 .692/.6) 58.75%,color(display-p3 .6578 .6578 .6578/.52) 64.74%,color(display-p3 .621 .621 .621/.42) 70.96%,color(display-p3 .5817 .5817 .5817/.33) 77.5%,color(display-p3 .5401 .5401 .5401/.22) 84.46%,color(display-p3 .4965 .4965 .4965/.11) 91.93%,color(display-p3 .451 .451 .451/0) 100%)}}@media (prefers-reduced-transparency:reduce){.masked-content{-webkit-mask-image:none;mask-image:none}}.mask-scrollbars{--scrollbar-width:10px;clip-path:inset(-100vh var(--scrollbar-width)0 0);clip-path:inset(-100svh var(--scrollbar-width)0 0)}.bg-thread--header{height:var(--composer-bar_height);background:linear-gradient(to bottom,transparent 0%,transparent 50%,var(--main-surface-primary)50%,var(--main-surface-primary)100%);-webkit-mask-image:var(--mask-fill),var(--composer-bar_skeleton);-webkit-mask-image:var(--mask-fill),var(--composer-bar_skeleton);-webkit-mask-image:var(--mask-fill),var(--composer-bar_skeleton);-webkit-mask-image:var(--mask-fill),var(--composer-bar_skeleton);mask-image:var(--mask-fill),var(--composer-bar_skeleton);-webkit-mask-composite:source-out;-webkit-mask-source-type:luminance;-webkit-mask-composite:source-out;-webkit-mask-source-type:luminance;-webkit-mask-composite:source-out;-webkit-mask-source-type:luminance;-webkit-mask-position:top,top;mask-position:top,top;-webkit-mask-size:auto;mask-size:auto;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-composite:source-out;mask-composite:subtract;-webkit-mask-source-type:luminance;mask-mode:luminance}@media (prefers-reduced-transparency:reduce){.bg-thread--header{-webkit-mask-image:none;mask-image:none}}.bg-thread--footer{background:var(--main-surface-primary);flex:1}:root{--spring-fast-duration:.667s;--spring-fast:linear(0,.01942 1.83%,.07956 4.02%,.47488 13.851%,.65981 19.572%,.79653 25.733%,.84834 29.083%,.89048 32.693%,.9246 36.734%,.95081 41.254%,.97012 46.425%,.98361 52.535%,.99665 68.277%,.99988);--spring-common-duration:.667s;--spring-common:linear(0,.00506 1.18%,.02044 2.46%,.08322 5.391%,.46561 17.652%,.63901 24.342%,.76663 31.093%,.85981 38.454%,.89862 42.934%,.92965 47.845%,.95366 53.305%,.97154 59.516%,.99189 74.867%,.9991);--spring-standard:var(--spring-common);--spring-slow-bounce-duration:1.167s;--spring-slow-bounce:linear(0,.00172 0.51%,.00682 1.03%,.02721 2.12%,.06135 3.29%,.11043 4.58%,.21945 6.911%,.59552 14.171%,.70414 16.612%,.79359 18.962%,.86872 21.362%,.92924 23.822%,.97589 26.373%,1.01 29.083%,1.0264 31.043%,1.03767 33.133%,1.04411 35.404%,1.04597 37.944%,1.04058 42.454%,1.01119 55.646%,1.00137 63.716%,.99791 74.127%,.99988);--spring-bounce-duration:.833s;--spring-bounce:linear(0,.00541 1.29%,.02175 2.68%,.04923 4.19%,.08852 5.861%,.17388 8.851%,.48317 18.732%,.57693 22.162%,.65685 25.503%,.72432 28.793%,.78235 32.163%,.83182 35.664%,.87356 39.354%,.91132 43.714%,.94105 48.455%,.96361 53.705%,.97991 59.676%,.9903 66.247%,.99664 74.237%,.99968 84.358%,1.00048);--spring-fast-bounce-duration:1s;--spring-fast-bounce:linear(0,.00683 1.14%,.02731 2.35%,.11137 5.091%,.59413 15.612%,.78996 20.792%,.92396 25.953%,.97109 28.653%,1.00624 31.503%,1.03801 36.154%,1.0477 41.684%,1.00242 68.787%,.99921);--easing-spring-elegant-duration:.58171s;--easing-spring-elegant:linear(0 0%,.005927 1%,.022466 2%,.047872 3%,.080554 4%,.119068 5%,.162116 6%,.208536 7.0%,.2573 8%,.3075 9%,.358346 10%,.409157 11%,.45935 12%,.508438 13%,.556014 14.0%,.601751 15%,.645389 16%,.686733 17%,.72564 18%,.762019 19%,.795818 20%,.827026 21%,.855662 22%,.881772 23%,.905423 24%,.926704 25%,.945714 26%,.962568 27%,.977386 28.0%,.990295 29.0%,1.00143 30%,1.01091 31%,1.01888 32%,1.02547 33%,1.03079 34%,1.03498 35%,1.03816 36%,1.04042 37%,1.04189 38%,1.04266 39%,1.04283 40%,1.04247 41%,1.04168 42%,1.04052 43%,1.03907 44%,1.03737 45%,1.03549 46%,1.03348 47%,1.03138 48%,1.02922 49%,1.02704 50%,1.02486 51%,1.02272 52%,1.02063 53%,1.01861 54%,1.01667 55.0%,1.01482 56.0%,1.01307 57.0%,1.01142 58.0%,1.00989 59%,1.00846 60%,1.00715 61%,1.00594 62%,1.00485 63%,1.00386 64%,1.00296 65%,1.00217 66%,1.00147 67%,1.00085 68%,1.00031 69%,.999849 70%,.999457 71%,.999128 72%,.998858 73%,.99864 74%,.99847 75%,.998342 76%,.998253 77%,.998196 78%,.998169 79%,.998167 80%,.998186 81%,.998224 82%,.998276 83%,.998341 84%,.998415 85%,.998497 86%,.998584 87%,.998675 88%,.998768 89%,.998861 90%,.998954 91%,.999045 92%,.999134 93%,.99922 94%,.999303 95%,.999381 96%,.999455 97%,.999525 98%,.999589 99%,.99965 100%);--easing-common:linear(0,0,.0001,.0002,.0003,.0005,.0007,.001,.0013,.0016,.002,.0024,.0029,.0033,.0039,.0044,.005,.0057,.0063,.007,.0079,.0086,.0094,.0103,.0112,.0121,.0132 1.84%,.0153,.0175,.0201,.0226,.0253,.0283,.0313,.0345,.038,.0416,.0454,.0493,.0535,.0576,.0621,.0667,.0714,.0764,.0816 5.04%,.0897,.098 5.62%,.1071,.1165,.1263 6.56%,.137,.1481 7.25%,.1601 7.62%,.1706 7.94%,.1819 8.28%,.194,.2068 9.02%,.2331 9.79%,.2898 11.44%,.3151 12.18%,.3412 12.95%,.3533,.365 13.66%,.3786,.3918,.4045,.4167,.4288,.4405,.452,.4631 16.72%,.4759,.4884,.5005,.5124,.5242,.5354,.5467,.5576,.5686,.5791,.5894,.5995,.6094,.6194,.6289,.6385,.6477,.6569,.6659 24.45%,.6702,.6747,.6789,.6833,.6877,.6919,.696,.7002,.7043,.7084,.7125,.7165,.7205,.7244,.7283,.7321,.7358,.7396,.7433,.7471,.7507,.7544,.7579,.7615,.7649,.7685,.7718,.7752,.7786,.782,.7853,.7885,.7918,.7951,.7982,.8013,.8043,.8075,.8104,.8135,.8165,.8195,.8224,.8253,.8281,.8309,.8336,.8365,.8391,.8419,.8446,.8472,.8499,.8524,.855,.8575,.8599,.8625 37.27%,.8651,.8678,.8703,.8729,.8754,.8779,.8803,.8827,.8851,.8875,.8898,.892,.8942,.8965,.8987,.9009,.903,.9051,.9071,.9092,.9112,.9132,.9151,.9171,.919,.9209,.9227,.9245,.9262,.928,.9297,.9314,.9331,.9347,.9364,.9379,.9395,.941,.9425,.944,.9454,.9469,.9483,.9497,.951,.9524,.9537,.955,.9562,.9574,.9586,.9599,.961,.9622,.9633,.9644,.9655,.9665,.9676,.9686,.9696,.9705,.9715,.9724,.9733,.9742,.975,.9758,.9766,.9774,.9782,.9789,.9796,.9804,.9811,.9817,.9824,.9831,.9837,.9843,.9849,.9855,.986,.9866,.9871,.9877,.9882,.9887,.9892,.9896 70.56%,.9905 71.67%,.9914 72.82%,.9922,.9929 75.2%,.9936 76.43%,.9942 77.71%,.9948 79.03%,.9954 80.39%,.9959 81.81%,.9963 83.28%,.9968 84.82%,.9972 86.41%,.9975 88.07%,.9979 89.81%,.9982 91.64%,.9984 93.56%,.9987 95.58%,.9989 97.72%,.9991)}@supports not (white-space-collapse:collapse){:root :root{--easing-common:ease-in-out;--spring-common:ease-in-out;--spring-bounce:ease-in-out;--spring-fast:ease-in-out;--spring-fast-bounce:ease-in-out;--spring-slow-bounce:ease-in-out}}@supports not (transition-timing-function:linear(0, 0 0%)){:root :root{--easing-common:ease-in-out;--spring-common:ease-in-out;--spring-bounce:ease-in-out;--spring-fast:ease-in-out;--spring-fast-bounce:ease-in-out;--spring-slow-bounce:ease-in-out}}@font-face{font-family:Circle;src:url(data:font/woff2;base64,d09GMk9UVE8AAAM0AAkAAAAABcgAAALuAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADYJIBmAAfgE2AiQDDAQGBYRyByAbIwVRlKvJFsDPBJtObcFDgzooFmKOOuZpZMG4Jg7aB8Nn8fzDvXrufz9r8tgCAU4XFVvjosSo0yqWv9Z+b8++or6Y3y3ikk0laqIkfBkSQzINien2vweAH79N8JdohCbbWndr/HZ5v86kXxrEqV+cqnlKNBcLjY0Bj8Ka512LSgsolgt1Wa1Wr27atM/jBW21RrW55g21jw81IoJNHn2c/z24BlCQTVVvW09zVvtAY1dzeOZwYxugoDCNO2g8kMZDDRpPRuPFajC3qWY31RzV9+loj/h/e6ud/0/bfxcbEILkKpVrQNhwTzuJycoVI0S9RjZZES7WjkQvApCsAhsUn3SuWdP3UZ0zRZT+X1OW1h0OGr9NflM3xRvmiClHqQORzvz/tQbQb7L8b7XUFdZrb+h13MhqctFw/8PP+snI1CnebrExOvET/Fh/hn+knPwQZw89wnNvi+62ERUZoHLr9BC1nCwROqghul1go6hTCVTs54ZppNw6x+jkYtzYdoEMTuGT8KCP/A/hDIeWUloqM4VXWm2g5T0CrvPF5g3kAs04zXJGkI7P96za7LmtKwgBAmhGzB07gBpPeEUAKyzEwwWJxIBzFaQeHZwg6BYQxo6W2Qwz739fUTpv+v/c+Xy3Sv6VF/uN3w8uFpdbNkDuXnWVGkBhvGn75R1LYEgq295Z+QHimbpBIbxAAQtPAhA2QAAaMjYQQHHzONnK8R1EFN9lrZmfUxvmFzjzl5dsLLNQqwDEx+49z7B0yrNi3SQ58LwmAy/AqeOtOWduzoY8+2s/wMFgbxAWiEesMNZAalIE2r8JllitrXeokZEbwVJpR0hSXFLwa+wftjSPNWMSERMRGxMrEi0DVYcfdnhxQ66Eqt62nmYsq32gsaspM4cb2ypPtQ531Q+IIoj9J0lKy0pzkjTFKxoOtd8ODLb39mD0t/UONT71Ry6QDBlaIonr767vbJaUtzSPNQOBmOXFihFLROyYXvXnrUOTszoAAA==)format("woff2");font-style:normal;font-weight:400;font-display:swap}@font-face{font-family:Circle;src:url(data:font/woff2;base64,d09GMk9UVE8AAANIAAkAAAAABkwAAAMCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADYJQBmAAfgE2AiQDDAQGBYVwByAbqQVRlMVNBfiRkLl5oxlVNKr4xQKiOJqYMp0zZd4iyiabvWs/aWpaEhyiBiMQGoNCIoys0xiDZnJteLRGXET3IpoQq697VFOmBlImcVhpy3pWuy6ZGDJnDoFpIwM96olCINQDUebsKzfc8cml+mMBP82Lfx5kKvx3Td2DFtCONwWq1Ru/VIs7/gTVpl7hgkiHscva/P1RKvnkOj2uDshaX5Id6vvRLs7Q7ZY7RUIphhAYQskQ6gJDmGIIZ4YIVbgw4aJK86iJ8ai2EeK+a2PFayPSEw4h7uwclpQxdejLyi11M9Iy2h0j4eJMBI28mehJINkCFioovm/Yah6VpgBJUm48kUyWnPA1xAhNmKY1S5qwFaT01WKAtvHEg6QZc9todjOVtRlP+hmjzDDS5vtMPD748Cgn0q2zV69y9Mytow/50QcHH4tnHBQWHuslA/3B8O2e6uPdV9vO1B/lSKo5WCl4o2ahQUcDvW2kuxvh3SOtegPX6+drRCVHhYM1R9HgaP3ZtqvIQwHcGn6o8wf644VngrsJ4QBWcbQHGrW2K7XgmT5uPpAHTOivlgPGIeL+mbnYY7xhj5AEAtSqfMIBaNDgjWfcMFRmHIrAIqAO7J4cqgRylIjSHx27HeBe+8o/qp1Xbb/IqsC9ZI03+w/fbWoexLpPI+sf04PMBbjGKDw6XInbdQiytiHo/3RWkeUd9IkyXjTYfUMA4QsCKCpfBGjhAhFgEqQAAWZZygUw+FhGgI2LIwiw404iwEWQixiaHEGSvMqDgqv5QpHqDyV0WChLs4GKVj5Q18zvoKFe1Xk/BxaI0I2NKfxfK8J/W710UVzebArQ6NFEpCWN1fGWFBQegKAjSBCctI7wij+coRcCJGQgy7A42Q3Te14v7+6FuamjlQMEsKxdJHYlel9kJ5adv7kxHe2kcBAeviIZGBpwSO2aZ7b9TXUzD/i7C8jF1drRAeiL2ZWjm6Rq8sFp4jKIQOBI9iJbyNGt7alX974oJIgBsRgsHDkMjr/FbPeiAAAA)format("woff2");font-style:normal;font-weight:600;font-display:swap}@font-face{font-display:swap;font-family:OpenAI Sans;font-style:normal;font-weight:400;src:url(https://cdn.openai.com/common/fonts/openai-sans/v2/OpenAISans-Regular.woff2)format("woff2")}@font-face{font-family:Atkinson Hyperlegible Mono;font-style:normal;font-optical-sizing:auto;font-feature-settings:"ccmp" on,"frac" on,"locl" on,"mark" on,"mkmk" on;font-weight:200 800;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-synthesis:none;font-display:swap;src:url(https://cdn.openai.com/common/fonts/mono/hyperlegible-mono-latin.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Atkinson Hyperlegible Mono;font-style:normal;font-optical-sizing:auto;font-weight:200 800;font-display:swap;src:url(https://cdn.openai.com/common/fonts/mono/hyperlegible-mono-latin-ext.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-display:swap;font-family:OpenAI Sans;font-style:italic;font-weight:400;src:url(https://cdn.openai.com/common/fonts/openai-sans/v2/OpenAISans-RegularItalic.woff2)format("woff2")}@font-face{font-display:swap;font-family:OpenAI Sans;font-style:normal;font-weight:500;src:url(https://cdn.openai.com/common/fonts/openai-sans/v2/OpenAISans-Medium.woff2)format("woff2")}@font-face{font-display:swap;font-family:OpenAI Sans;font-style:italic;font-weight:500;src:url(https://cdn.openai.com/common/fonts/openai-sans/v2/OpenAISans-MediumItalic.woff2)format("woff2")}@font-face{font-display:swap;font-family:OpenAI Sans;font-style:normal;font-weight:600;src:url(https://cdn.openai.com/common/fonts/openai-sans/v2/OpenAISans-Semibold.woff2)format("woff2")}@font-face{font-display:swap;font-family:OpenAI Sans;font-style:italic;font-weight:600;src:url(https://cdn.openai.com/common/fonts/openai-sans/v2/OpenAISans-SemiboldItalic.woff2)format("woff2")}@font-face{font-display:swap;font-family:OpenAI Sans;font-style:normal;font-weight:700;src:url(https://cdn.openai.com/common/fonts/openai-sans/v2/OpenAISans-Bold.woff2)format("woff2")}@font-face{font-display:swap;font-family:OpenAI Sans;font-style:italic;font-weight:700;src:url(https://cdn.openai.com/common/fonts/openai-sans/v2/OpenAISans-BoldItalic.woff2)format("woff2")}@layer theme{:root,:host{--font-sans:"ui-sans-serif","-apple-system","system-ui","Segoe UI","Helvetica","Apple Color Emoji","Arial","sans-serif","Segoe UI Emoji","Segoe UI Symbol";--font-mono:"ui-monospace","SFMono-Regular","SF Mono","Menlo","Consolas","Liberation Mono","monospace";--spacing:.25rem;--breakpoint-md:48rem;--breakpoint-lg:64rem;--breakpoint-xl:80rem;--breakpoint-2xl:96rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--font-weight-extralight:200;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--shadow-lg:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--drop-shadow-xs:0 1px 1px #0000000d;--drop-shadow-sm:0 1px 2px #00000026;--drop-shadow-md:0 3px 3px #0000001f;--drop-shadow-lg:0 4px 4px #00000026;--drop-shadow-xl:0 9px 7px #0000001a;--drop-shadow-2xl:0 25px 25px #00000026;--ease-in:cubic-bezier(.4,0,1,1);--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--blur-2xl:40px;--blur-3xl:64px;--aspect-video:16/9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:"ui-sans-serif","-apple-system","system-ui","Segoe UI","Helvetica","Apple Color Emoji","Arial","sans-serif","Segoe UI Emoji","Segoe UI Symbol";--default-mono-font-family:"ui-monospace","SFMono-Regular","SF Mono","Menlo","Consolas","Liberation Mono","monospace";--text-heading-2:1.5rem;--text-heading-2--line-height:1.75rem;--text-heading-2--letter-spacing:-.015625rem;--text-heading-2--font-weight:600;--text-heading-app:1.75rem;--text-heading-app--line-height:2.125rem;--text-heading-app--letter-spacing:.02375rem;--text-heading-app--font-weight:500;--text-heading-3:1.125rem;--text-heading-3--line-height:1.625rem;--text-heading-3--letter-spacing:-.028125rem;--text-heading-3--font-weight:600;--text-body-regular:1rem;--text-body-regular--line-height:1.625rem;--text-body-regular--letter-spacing:-.025rem;--text-body-regular--font-weight:400;--text-body-small-regular:.875rem;--text-body-small-regular--line-height:1.125rem;--text-body-small-regular--letter-spacing:-.01875rem;--text-body-small-regular--font-weight:400;--text-footnote-regular:.8125rem;--text-footnote-regular--line-height:1.125rem;--text-footnote-regular--letter-spacing:-.005rem;--text-footnote-regular--font-weight:400;--text-footnote-medium:.8125rem;--text-footnote-medium--line-height:1.25rem;--text-footnote-medium--letter-spacing:-.005rem;--text-footnote-medium--font-weight:500;--text-monospace:.9375rem;--text-monospace--line-height:1.375rem;--text-monospace--letter-spacing:-.025rem;--text-monospace--font-weight:400;--text-caption-regular:.75rem;--text-caption-regular--line-height:1rem;--text-caption-regular--letter-spacing:-.00625rem;--text-caption-regular--font-weight:400;--interactive-bg-default-primary:var(--interactive-bg-primary-default);--interactive-bg-default-secondary:var(--interactive-bg-secondary-default);--interactive-bg-default-accent:var(--interactive-bg-accent-default);--interactive-bg-default-danger-primary:var(--interactive-bg-danger-primary-default);--interactive-bg-hover-primary:var(--interactive-bg-primary-hover);--interactive-bg-hover-secondary:var(--interactive-bg-secondary-hover);--interactive-bg-hover-accent:var(--interactive-bg-accent-hover);--interactive-bg-hover-danger-primary:var(--interactive-bg-danger-primary-hover);--interactive-bg-press-primary:var(--interactive-bg-primary-press);--interactive-bg-press-secondary:var(--interactive-bg-secondary-press);--interactive-bg-press-accent:var(--interactive-bg-accent-press);--interactive-bg-press-danger-primary:var(--interactive-bg-danger-primary-press);--interactive-bg-inactive-primary:var(--interactive-bg-primary-inactive);--interactive-bg-inactive-secondary:var(--interactive-bg-secondary-inactive);--interactive-bg-inactive-accent:var(--interactive-bg-accent-inactive);--interactive-bg-inactive-danger-primary:var(--interactive-bg-danger-primary-inactive);--interactive-bg-selected-primary:var(--interactive-bg-primary-selected);--interactive-bg-selected-secondary:var(--interactive-bg-secondary-selected);--interactive-bg-selected-accent:var(--interactive-bg-accent-default);--interactive-bg-selected-danger-primary:var(--interactive-bg-danger-primary-default);--interactive-button-bg-default-primary:var(--interactive-bg-primary-default);--interactive-button-bg-default-secondary:var(--interactive-bg-secondary-default);--interactive-button-bg-default-destructive:var(--interactive-bg-danger-primary-default);--interactive-button-bg-default-sec-destructive:var(--interactive-bg-danger-secondary-default);--interactive-button-bg-hover-primary:var(--interactive-bg-primary-hover);--interactive-button-bg-hover-secondary:var(--interactive-bg-secondary-hover);--interactive-button-bg-hover-destructive:var(--interactive-bg-danger-primary-hover);--interactive-button-bg-hover-sec-destructive:var(--interactive-bg-danger-secondary-hover);--interactive-button-bg-press-primary:var(--interactive-bg-primary-press);--interactive-button-bg-press-secondary:var(--interactive-bg-secondary-press);--interactive-button-bg-press-destructive:var(--interactive-bg-danger-primary-press);--interactive-button-bg-press-sec-destructive:var(--interactive-bg-danger-secondary-press);--interactive-button-bg-inactive-primary:var(--interactive-bg-primary-inactive);--interactive-button-bg-inactive-secondary:var(--interactive-bg-secondary-inactive);--interactive-button-bg-inactive-destructive:var(--interactive-bg-danger-primary-inactive);--interactive-button-bg-inactive-sec-destructive:var(--interactive-bg-danger-secondary-inactive);--interactive-button-bg-selected-primary:var(--interactive-bg-primary-selected);--interactive-button-bg-selected-secondary:var(--interactive-bg-secondary-selected);--interactive-button-bg-selected-destructive:var(--interactive-bg-danger-primary-default);--interactive-button-bg-selected-sec-destructive:var(--interactive-bg-danger-secondary-default);--interactive-border-default-secondary:var(--interactive-border-secondary-default);--interactive-border-hover-secondary:var(--interactive-border-secondary-hover);--interactive-border-press-secondary:var(--interactive-border-secondary-press);--interactive-border-inactive-secondary:var(--interactive-border-secondary-inactive);--interactive-border-selected-secondary:var(--interactive-border-secondary-default);--interactive-button-border-default-primary:transparent;--interactive-button-border-default-secondary:var(--interactive-border-secondary-default);--interactive-button-border-default-destructive:transparent;--interactive-button-border-default-sec-destructive:var(--interactive-border-danger-secondary-default);--interactive-button-border-hover-primary:transparent;--interactive-button-border-hover-secondary:var(--interactive-border-secondary-hover);--interactive-button-border-hover-destructive:transparent;--interactive-button-border-hover-sec-destructive:var(--interactive-border-danger-secondary-hover);--interactive-button-border-press-primary:transparent;--interactive-button-border-press-secondary:var(--interactive-border-secondary-press);--interactive-button-border-press-destructive:transparent;--interactive-button-border-press-sec-destructive:var(--interactive-border-danger-secondary-press);--interactive-button-border-inactive-primary:transparent;--interactive-button-border-inactive-secondary:var(--interactive-border-secondary-inactive);--interactive-button-border-inactive-destructive:transparent;--interactive-button-border-inactive-sec-destructive:var(--interactive-border-danger-secondary-inactive);--interactive-button-border-selected-primary:transparent;--interactive-button-border-selected-secondary:var(--interactive-border-secondary-default);--interactive-button-border-selected-destructive:transparent;--interactive-button-border-selected-sec-destructive:var(--interactive-border-danger-secondary-default);--interactive-label-default-primary:var(--interactive-label-primary-default);--interactive-label-default-secondary:var(--interactive-label-secondary-default);--interactive-label-default-tertiary:var(--interactive-label-tertiary-default);--interactive-label-default-accent:var(--interactive-label-accent-default);--interactive-label-hover-primary:var(--interactive-label-primary-hover);--interactive-label-hover-secondary:var(--interactive-label-secondary-hover);--interactive-label-hover-tertiary:var(--interactive-label-tertiary-hover);--interactive-label-hover-accent:var(--interactive-label-accent-hover);--interactive-label-press-primary:var(--interactive-label-primary-press);--interactive-label-press-secondary:var(--interactive-label-secondary-press);--interactive-label-press-tertiary:var(--interactive-label-tertiary-press);--interactive-label-press-accent:var(--interactive-label-accent-press);--interactive-label-inactive-primary:var(--interactive-label-primary-inactive);--interactive-label-inactive-secondary:var(--interactive-label-secondary-inactive);--interactive-label-inactive-tertiary:var(--interactive-label-tertiary-inactive);--interactive-label-inactive-accent:var(--interactive-label-accent-inactive);--interactive-label-selected-primary:var(--interactive-label-primary-selected);--interactive-label-selected-secondary:var(--interactive-label-secondary-selected);--interactive-label-selected-tertiary:var(--interactive-label-tertiary-selected);--interactive-label-selected-accent:var(--interactive-label-accent-selected);--interactive-button-label-default-primary:var(--interactive-label-primary-default);--interactive-button-label-default-secondary:var(--interactive-label-secondary-default);--interactive-button-label-default-destructive:var(--interactive-label-danger-primary-default);--interactive-button-label-default-sec-destructive:var(--interactive-label-danger-secondary-default);--interactive-button-label-hover-primary:var(--interactive-label-primary-hover);--interactive-button-label-hover-secondary:var(--interactive-label-secondary-hover);--interactive-button-label-hover-destructive:var(--interactive-label-danger-primary-hover);--interactive-button-label-hover-sec-destructive:var(--interactive-label-danger-secondary-hover);--interactive-button-label-press-primary:var(--interactive-label-primary-press);--interactive-button-label-press-secondary:var(--interactive-label-secondary-press);--interactive-button-label-press-destructive:var(--interactive-label-danger-primary-press);--interactive-button-label-press-sec-destructive:var(--interactive-label-danger-secondary-press);--interactive-button-label-inactive-primary:var(--interactive-label-primary-inactive);--interactive-button-label-inactive-secondary:var(--interactive-label-secondary-inactive);--interactive-button-label-inactive-destructive:var(--interactive-label-danger-primary-inactive);--interactive-button-label-inactive-sec-destructive:var(--interactive-label-danger-secondary-inactive);--interactive-button-label-selected-primary:var(--interactive-label-primary-selected);--interactive-button-label-selected-secondary:var(--interactive-label-secondary-selected);--interactive-button-label-selected-destructive:var(--interactive-label-danger-primary-default);--interactive-button-label-selected-sec-destructive:var(--interactive-label-danger-secondary-default);--interactive-icon-default-accent:var(--interactive-icon-accent-default);--interactive-icon-hover-accent:var(--interactive-icon-accent-hover);--interactive-icon-press-accent:var(--interactive-icon-accent-press);--interactive-icon-inactive-accent:var(--interactive-icon-accent-inactive);--interactive-icon-selected-accent:var(--interactive-icon-accent-selected);--interactive-button-icon-default-primary:var(--interactive-icon-primary-default);--interactive-button-icon-default-secondary:var(--interactive-icon-secondary-default);--interactive-button-icon-default-destructive:var(--interactive-icon-danger-primary-default);--interactive-button-icon-default-sec-destructive:var(--interactive-icon-danger-secondary-default);--interactive-button-icon-hover-primary:var(--interactive-icon-primary-hover);--interactive-button-icon-hover-secondary:var(--interactive-icon-secondary-hover);--interactive-button-icon-hover-destructive:var(--interactive-icon-danger-primary-hover);--interactive-button-icon-hover-sec-destructive:var(--interactive-icon-danger-secondary-hover);--interactive-button-icon-press-primary:var(--interactive-icon-primary-press);--interactive-button-icon-press-secondary:var(--interactive-icon-secondary-press);--interactive-button-icon-press-destructive:var(--interactive-icon-danger-primary-press);--interactive-button-icon-press-sec-destructive:var(--interactive-icon-danger-secondary-press);--interactive-button-icon-inactive-primary:var(--interactive-icon-primary-inactive);--interactive-button-icon-inactive-secondary:var(--interactive-icon-secondary-inactive);--interactive-button-icon-inactive-destructive:var(--interactive-icon-danger-primary-inactive);--interactive-button-icon-inactive-sec-destructive:var(--interactive-icon-danger-secondary-inactive);--interactive-button-icon-selected-primary:var(--interactive-icon-primary-selected);--interactive-button-icon-selected-secondary:var(--interactive-icon-secondary-selected);--interactive-button-icon-selected-destructive:var(--interactive-icon-danger-primary-default);--interactive-button-icon-selected-sec-destructive:var(--interactive-icon-danger-secondary-default);--tap-padding-pointer:32px;--tap-padding-mobile:44px;--focus-outline-margin-default:4px}:root{--green-25:#edfaf2;--green-50:#d9f4e4;--green-75:#b8ebcc;--green-100:#8cdfad;--green-200:#66d492;--green-300:#40c977;--green-400:#04b84c;--green-500:#00a240;--green-600:#008635;--green-700:#00692a;--green-800:#004f1f;--green-900:#003716;--green-950:#011c0b;--green-1000:#001207;--green-a25:#04b84c14;--green-a50:#04b84c26;--green-a75:#04b84c4a;--green-a100:#04b84c73;--green-a200:#04b84c99;--green-a300:#04b84cbf;--purple-25:#f9f5fe;--purple-50:#efe5fe;--purple-75:#e0cefd;--purple-100:#ceb0fb;--purple-200:#be95fa;--purple-300:#ad7bf9;--purple-400:#924ff7;--purple-500:#8046d9;--purple-600:#6b3ab4;--purple-700:#532d8d;--purple-800:#3f226a;--purple-900:#2c184a;--purple-950:#160c25;--purple-1000:#100a19;--purple-a25:#924ff70f;--purple-a50:#924ff726;--purple-a75:#924ff747;--purple-a100:#924ff773;--purple-a200:#924ff799;--purple-a300:#924ff7bf;--blue-25:#f5faff;--blue-50:#e5f3ff;--blue-75:#cce6ff;--blue-100:#99ceff;--blue-200:#66b5ff;--blue-300:#339cff;--blue-400:#0285ff;--blue-500:#0169cc;--blue-600:#004f99;--blue-700:#003f7a;--blue-800:#013566;--blue-900:#00284d;--blue-950:#000e1a;--blue-1000:#000d19;--blue-a25:#0285ff0a;--blue-a50:#0285ff21;--blue-a75:#0285ff40;--blue-a100:#0285ff66;--blue-a200:#0285ff99;--blue-a300:#0285ffcc;--orange-25:#fff5f0;--orange-50:#ffe7d9;--orange-75:#ffcfb4;--orange-100:#ffb790;--orange-200:#ff9e6c;--orange-300:#ff8549;--orange-400:#fb6a22;--orange-500:#e25507;--orange-600:#b9480d;--orange-700:#923b0f;--orange-800:#6d2e0f;--orange-900:#4a2206;--orange-950:#281105;--orange-1000:#211107;--orange-a25:#fb6a2212;--orange-a50:#fb6a2229;--orange-a75:#fb6a2254;--orange-a100:#fb6a227a;--orange-a200:#fb6a22a6;--orange-a300:#fb6a22cf;--red-25:#fff0f0;--red-50:#ffe1e0;--red-75:#ffc6c5;--red-100:#ffa4a2;--red-200:#ff8583;--red-300:#ff6764;--red-400:#fa423e;--red-500:#e02e2a;--red-600:#ba2623;--red-700:#911e1b;--red-800:#6e1615;--red-900:#4d100e;--red-950:#280b0a;--red-1000:#1f0909;--red-a25:#fa423e14;--red-a50:#fa423e29;--red-a75:#fa423e4c;--red-a100:#fa423e7a;--red-a200:#fa423ea3;--red-a300:#fa423ec9;--pink-25:#fff4f9;--pink-50:#ffe8f3;--pink-75:#ffd4e8;--pink-100:#ffbada;--pink-200:#ffa3ce;--pink-300:#ff8cc1;--pink-400:#ff66ad;--pink-500:#e04c91;--pink-600:#ba437a;--pink-700:#963c67;--pink-800:#6e2c4a;--pink-900:#4d1f34;--pink-950:#29101c;--pink-1000:#1a0a11;--pink-a25:#ff66ad14;--pink-a50:#ff66ad29;--pink-a75:#ff66ad47;--pink-a100:#ff66ad73;--pink-a200:#ff66ad99;--pink-a300:#ff66adc2;--yellow-25:#fffbed;--yellow-50:#fff6d9;--yellow-75:#ffeeb8;--yellow-100:#ffe48c;--yellow-200:#ffdb66;--yellow-300:#ffd240;--yellow-400:#ffc300;--yellow-500:#e0ac00;--yellow-600:#ba8e00;--yellow-700:#916f00;--yellow-800:#6e5400;--yellow-900:#4d3b00;--yellow-950:#261d00;--yellow-1000:#1a1400;--yellow-a25:#ffc30014;--yellow-a50:#ffc30026;--yellow-a75:#ffc30045;--yellow-a100:#ffc30073;--yellow-a200:#ffc30096;--yellow-a300:#ffc300bd}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option:dir(ltr){padding-left:20px}:where(select:is([multiple],[size])) optgroup option:dir(rtl){padding-right:20px}:dir(ltr)::file-selector-button{margin-right:4px}:dir(rtl)::file-selector-button{margin-left:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html,.light,.dark .light{--text-quaternary:#00000030}.dark{--text-quaternary:#ffffff69}:root{--mkt-header-height:calc(16*var(--spacing))}@media (pointer:coarse){:root{--mkt-header-height:calc(18*var(--spacing))}}*,:after,:before,::backdrop{border-color:var(--border-light,currentColor)}::file-selector-button{border-color:var(--border-light,currentColor)}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}html,body{background-color:var(--bg-primary);color:var(--text-primary)}::selection{background-color:var(--theme-user-selection-bg);text-shadow:none}select:not([multiple]):where(:not([size]),[size="1"]),.form-select{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239B9B9B' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")}:root{--default-theme-user-msg-bg:var(--message-surface);--default-theme-user-msg-text:var(--text-primary);--default-theme-submit-btn-bg:#000;--default-theme-submit-btn-text:#fff;--default-theme-secondary-btn-bg:var(--gray-100);--default-theme-secondary-btn-text:var(--text-primary);--default-theme-user-selection-bg:var(--blue-300)}@supports (color:color-mix(in lab, red, red)){:root{--default-theme-user-selection-bg:color-mix(in oklab,var(--blue-300)35%,transparent)}}:root{--default-theme-attribution-highlight-bg:var(--yellow-75);--default-theme-entity-accent:var(--blue-500);--formatted-text-highlight-bg:#bae6fdb3;--blue-theme-user-msg-bg:var(--blue-50);--blue-theme-user-msg-text:var(--blue-900);--blue-theme-submit-btn-bg:var(--blue-400);--blue-theme-submit-btn-text:#fff;--blue-theme-secondary-btn-bg:var(--blue-50);--blue-theme-secondary-btn-text:var(--blue-900);--blue-theme-user-selection-bg:var(--blue-300)}@supports (color:color-mix(in lab, red, red)){:root{--blue-theme-user-selection-bg:color-mix(in oklab,var(--blue-300)35%,transparent)}}:root{--blue-theme-entity-accent:var(--blue-500);--green-theme-user-msg-bg:var(--green-50);--green-theme-user-msg-text:var(--green-900);--green-theme-submit-btn-bg:var(--green-400);--green-theme-submit-btn-text:#fff;--green-theme-secondary-btn-bg:var(--green-50);--green-theme-secondary-btn-text:var(--green-900);--green-theme-user-selection-bg:var(--green-300)}@supports (color:color-mix(in lab, red, red)){:root{--green-theme-user-selection-bg:color-mix(in oklab,var(--green-300)35%,transparent)}}:root{--green-theme-entity-accent:var(--green-500);--yellow-theme-user-msg-bg:var(--yellow-50);--yellow-theme-user-msg-text:var(--yellow-900);--yellow-theme-submit-btn-bg:var(--yellow-400);--yellow-theme-submit-btn-text:#fff;--yellow-theme-secondary-btn-bg:var(--yellow-50);--yellow-theme-secondary-btn-text:var(--yellow-900);--yellow-theme-user-selection-bg:var(--yellow-300)}@supports (color:color-mix(in lab, red, red)){:root{--yellow-theme-user-selection-bg:color-mix(in oklab,var(--yellow-300)35%,transparent)}}:root{--yellow-theme-entity-accent:var(--yellow-500);--purple-theme-user-msg-bg:var(--purple-50);--purple-theme-user-msg-text:var(--purple-900);--purple-theme-submit-btn-bg:var(--purple-400);--purple-theme-submit-btn-text:#fff;--purple-theme-secondary-btn-bg:var(--purple-50);--purple-theme-secondary-btn-text:var(--purple-900);--purple-theme-user-selection-bg:var(--purple-300)}@supports (color:color-mix(in lab, red, red)){:root{--purple-theme-user-selection-bg:color-mix(in oklab,var(--purple-300)35%,transparent)}}:root{--purple-theme-entity-accent:var(--purple-500);--pink-theme-user-msg-bg:var(--pink-50);--pink-theme-user-msg-text:var(--pink-900);--pink-theme-submit-btn-bg:var(--pink-400);--pink-theme-submit-btn-text:#fff;--pink-theme-secondary-btn-bg:var(--pink-50);--pink-theme-secondary-btn-text:var(--pink-900);--pink-theme-user-selection-bg:var(--pink-300)}@supports (color:color-mix(in lab, red, red)){:root{--pink-theme-user-selection-bg:color-mix(in oklab,var(--pink-300)35%,transparent)}}:root{--pink-theme-entity-accent:var(--pink-500);--orange-theme-user-msg-bg:var(--orange-50);--orange-theme-user-msg-text:var(--orange-900);--orange-theme-submit-btn-bg:var(--orange-400);--orange-theme-submit-btn-text:#fff;--orange-theme-secondary-btn-bg:var(--orange-50);--orange-theme-secondary-btn-text:var(--orange-900);--orange-theme-user-selection-bg:var(--orange-300)}@supports (color:color-mix(in lab, red, red)){:root{--orange-theme-user-selection-bg:color-mix(in oklab,var(--orange-300)35%,transparent)}}:root{--orange-theme-entity-accent:var(--orange-500);--black-theme-user-msg-bg:#000;--black-theme-user-msg-text:#fff;--black-theme-submit-btn-bg:#000;--black-theme-submit-btn-text:#fff;--black-theme-secondary-btn-bg:var(--gray-100);--black-theme-secondary-btn-text:var(--text-primary);--black-theme-user-selection-bg:var(--gray-300)}@supports (color:color-mix(in lab, red, red)){:root{--black-theme-user-selection-bg:color-mix(in oklab,var(--gray-300)40%,transparent)}}:root{--black-theme-entity-accent:var(--gray-500)}.dark,.light .dark{--default-theme-submit-btn-bg:#fff;--default-theme-submit-btn-text:#000;--default-theme-user-msg-text:var(--text-primary);--default-theme-secondary-btn-bg:var(--gray-700);--default-theme-secondary-btn-text:#fff;--default-theme-user-selection-bg:color-mix(in oklab,var(--blue-200)40%,transparent);--default-theme-attribution-highlight-bg:var(--yellow-800);--formatted-text-highlight-bg:#0ea5e94d;--blue-theme-user-msg-bg:var(--blue-700);--blue-theme-user-msg-text:var(--blue-25);--blue-theme-submit-btn-bg:var(--blue-500);--blue-theme-secondary-btn-bg:var(--blue-600);--blue-theme-secondary-btn-text:var(--blue-25);--blue-theme-user-selection-bg:color-mix(in oklab,var(--blue-400)60%,transparent);--default-theme-entity-accent:var(--blue-300);--green-theme-user-msg-bg:var(--green-700);--green-theme-user-msg-text:var(--green-25);--green-theme-submit-btn-bg:var(--green-500);--green-theme-secondary-btn-bg:var(--green-600);--green-theme-secondary-btn-text:var(--green-25);--green-theme-user-selection-bg:color-mix(in oklab,var(--green-400)60%,transparent);--green-theme-entity-accent:var(--green-300);--yellow-theme-user-msg-bg:var(--yellow-700);--yellow-theme-user-msg-text:var(--yellow-25);--yellow-theme-submit-btn-bg:var(--yellow-500);--yellow-theme-secondary-btn-bg:var(--yellow-600);--yellow-theme-secondary-btn-text:var(--yellow-25);--yellow-theme-user-selection-bg:color-mix(in oklab,var(--yellow-400)50%,transparent);--yellow-theme-entity-accent:var(--yellow-300);--purple-theme-user-msg-bg:var(--purple-700);--purple-theme-user-msg-text:var(--purple-25);--purple-theme-submit-btn-bg:var(--purple-500);--purple-theme-secondary-btn-bg:var(--purple-600);--purple-theme-secondary-btn-text:var(--purple-25);--purple-theme-user-selection-bg:color-mix(in oklab,var(--purple-400)60%,transparent);--purple-theme-entity-accent:var(--purple-300);--pink-theme-user-msg-bg:var(--pink-700);--pink-theme-user-msg-text:var(--pink-25);--pink-theme-submit-btn-bg:var(--pink-500);--pink-theme-secondary-btn-bg:var(--pink-600);--pink-theme-secondary-btn-text:var(--pink-25);--pink-theme-user-selection-bg:color-mix(in oklab,var(--pink-400)60%,transparent);--pink-theme-entity-accent:var(--pink-300);--orange-theme-user-msg-bg:var(--orange-700);--orange-theme-user-msg-text:var(--orange-25);--orange-theme-submit-btn-bg:var(--orange-500);--orange-theme-secondary-btn-bg:var(--orange-600);--orange-theme-secondary-btn-text:var(--orange-25);--orange-theme-user-selection-bg:color-mix(in oklab,var(--orange-400)60%,transparent);--orange-theme-entity-accent:var(--orange-300);--black-theme-user-msg-bg:var(--gray-100);--black-theme-user-msg-text:#000;--black-theme-submit-btn-bg:#fff;--black-theme-submit-btn-text:#000;--black-theme-secondary-btn-bg:var(--gray-700);--black-theme-secondary-btn-text:#fff;--black-theme-user-selection-bg:color-mix(in oklab,var(--gray-600)40%,transparent);--black-theme-entity-accent:var(--gray-300)}:root,[data-chat-theme=default],[data-chat-theme=default] .dark{--theme-user-msg-bg:var(--default-theme-user-msg-bg);--theme-user-msg-text:var(--default-theme-user-msg-text);--theme-submit-btn-bg:var(--default-theme-submit-btn-bg);--theme-submit-btn-text:var(--default-theme-submit-btn-text);--theme-secondary-btn-bg:var(--default-theme-secondary-btn-bg);--theme-secondary-btn-text:var(--default-theme-secondary-btn-text);--theme-user-selection-bg:var(--default-theme-user-selection-bg);--theme-attribution-highlight-bg:var(--default-theme-attribution-highlight-bg);--theme-entity-accent:var(--default-theme-entity-accent)}[data-chat-theme=blue],[data-chat-theme=blue] .dark{--theme-user-msg-bg:var(--blue-theme-user-msg-bg);--theme-user-msg-text:var(--blue-theme-user-msg-text);--theme-submit-btn-bg:var(--blue-theme-submit-btn-bg);--theme-submit-btn-text:var(--blue-theme-submit-btn-text);--theme-secondary-btn-bg:var(--blue-theme-secondary-btn-bg);--theme-secondary-btn-text:var(--blue-theme-secondary-btn-text);--theme-user-selection-bg:var(--blue-theme-user-selection-bg);--theme-attribution-highlight-bg:var(--blue-theme-user-selection-bg);--theme-entity-accent:var(--blue-theme-entity-accent)}[data-chat-theme=green],[data-chat-theme=green] .dark{--theme-user-msg-bg:var(--green-theme-user-msg-bg);--theme-user-msg-text:var(--green-theme-user-msg-text);--theme-submit-btn-bg:var(--green-theme-submit-btn-bg);--theme-submit-btn-text:var(--green-theme-submit-btn-text);--theme-secondary-btn-bg:var(--green-theme-secondary-btn-bg);--theme-secondary-btn-text:var(--green-theme-secondary-btn-text);--theme-user-selection-bg:var(--green-theme-user-selection-bg);--theme-attribution-highlight-bg:var(--green-theme-user-selection-bg);--theme-entity-accent:var(--green-theme-entity-accent)}[data-chat-theme=yellow],[data-chat-theme=yellow] .dark{--theme-user-msg-bg:var(--yellow-theme-user-msg-bg);--theme-user-msg-text:var(--yellow-theme-user-msg-text);--theme-submit-btn-bg:var(--yellow-theme-submit-btn-bg);--theme-submit-btn-text:var(--yellow-theme-submit-btn-text);--theme-secondary-btn-bg:var(--yellow-theme-secondary-btn-bg);--theme-secondary-btn-text:var(--yellow-theme-secondary-btn-text);--theme-user-selection-bg:var(--yellow-theme-user-selection-bg);--theme-attribution-highlight-bg:var(--yellow-theme-user-selection-bg);--theme-entity-accent:var(--yellow-theme-entity-accent)}[data-chat-theme=purple],[data-chat-theme=purple] .dark{--theme-user-msg-bg:var(--purple-theme-user-msg-bg);--theme-user-msg-text:var(--purple-theme-user-msg-text);--theme-submit-btn-bg:var(--purple-theme-submit-btn-bg);--theme-submit-btn-text:var(--purple-theme-submit-btn-text);--theme-secondary-btn-bg:var(--purple-theme-secondary-btn-bg);--theme-secondary-btn-text:var(--purple-theme-secondary-btn-text);--theme-user-selection-bg:var(--purple-theme-user-selection-bg);--theme-attribution-highlight-bg:var(--purple-theme-user-selection-bg);--theme-entity-accent:var(--purple-theme-entity-accent)}[data-chat-theme=pink],[data-chat-theme=pink] .dark{--theme-user-msg-bg:var(--pink-theme-user-msg-bg);--theme-user-msg-text:var(--pink-theme-user-msg-text);--theme-submit-btn-bg:var(--pink-theme-submit-btn-bg);--theme-submit-btn-text:var(--pink-theme-submit-btn-text);--theme-secondary-btn-bg:var(--pink-theme-secondary-btn-bg);--theme-secondary-btn-text:var(--pink-theme-secondary-btn-text);--theme-user-selection-bg:var(--pink-theme-user-selection-bg);--theme-attribution-highlight-bg:var(--pink-theme-user-selection-bg);--theme-entity-accent:var(--pink-theme-entity-accent)}[data-chat-theme=orange],[data-chat-theme=orange] .dark{--theme-user-msg-bg:var(--orange-theme-user-msg-bg);--theme-user-msg-text:var(--orange-theme-user-msg-text);--theme-submit-btn-bg:var(--orange-theme-submit-btn-bg);--theme-submit-btn-text:var(--orange-theme-submit-btn-text);--theme-secondary-btn-bg:var(--orange-theme-secondary-btn-bg);--theme-secondary-btn-text:var(--orange-theme-secondary-btn-text);--theme-user-selection-bg:var(--orange-theme-user-selection-bg);--theme-entity-accent:var(--orange-theme-entity-accent)}[data-chat-theme=black],[data-chat-theme=black] .dark{--theme-user-msg-bg:var(--black-theme-user-msg-bg);--theme-user-msg-text:var(--black-theme-user-msg-text);--theme-submit-btn-bg:var(--black-theme-submit-btn-bg);--theme-submit-btn-text:var(--black-theme-submit-btn-text);--theme-secondary-btn-bg:var(--black-theme-secondary-btn-bg);--theme-secondary-btn-text:var(--black-theme-secondary-btn-text);--theme-user-selection-bg:var(--black-theme-user-selection-bg);--theme-attribution-highlight-bg:var(--black-theme-user-selection-bg);--theme-entity-accent:var(--black-theme-entity-accent)}h1{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}h2,h3{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}input[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;appearance:none;margin:0}input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;appearance:none;margin:0}@keyframes hive-log-fadeout{0%{background:#0285ff1a}to{background-color:#0000}}.hive-log{cursor:pointer;padding-inline:calc(var(--spacing)*1.5);padding-block:calc(var(--spacing)*.5);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);color:#8f8f8f;background-color:#0000000d;border-radius:3.40282e38px}@media (hover:hover){.hive-log:hover{background-color:var(--main-surface-tertiary);color:var(--text-primary)}}:root,[dir=ltr]{--start:left;--end:right;--to-end-unit:1;--is-ltr:unset;--is-rtl: }[dir=rtl]{--start:right;--end:left;--to-end-unit:-1;--is-ltr: ;--is-rtl:unset}[data-rtl-flip]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){transform-origin:50%;--tw-scale-x:-1;scale:var(--tw-scale-x)var(--tw-scale-y)}:root{--safe-area-max-inset-bottom:env(safe-area-max-inset-bottom,36px);--user-chat-width:70%;--sidebar-width:260px;--sidebar-section-margin-top:1.25rem;--sidebar-section-first-margin-top:.5rem;--sidebar-expanded-section-margin-bottom:1.25rem;--sidebar-collapsed-section-margin-bottom:.75rem;--sidebar-rail-width:calc(13*var(--spacing));--header-height:calc(13*var(--spacing))}@media (pointer:coarse){:root{--sidebar-rail-width:calc(14*var(--spacing));--header-height:calc(14*var(--spacing))}}:root:has(.images-app){--sidebar-bg:#ffffff29;--sidebar-login-pane-bg:#fff;--sidebar-mask-bg:transparent;--sidebar-moweb-bg:transparent;--sidebar-sticky-backdrop:blur(14px)}:root:has(.images-app):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--sidebar-bg:#00000029;--sidebar-login-pane-bg:#171717}:root:has(.mattress-app){--sidebar-bg:#ffffff29;--sidebar-mask-bg:transparent;--sidebar-moweb-bg:transparent;--sidebar-sticky-backdrop:blur(14px)}:root:has(.mattress-app):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--sidebar-bg:#00000029}:root:has(.mattress-app) #page-header{background-color:#0000!important}:root{--white:#fff;--black:#000;--gray-0:#fff;--gray-25:#fcfcfc;--gray-50:#f9f9f9;--gray-75:#f2f2f2;--gray-100:#ececec;--gray-150:#e8e8e8;--gray-200:#e3e3e3;--gray-250:#d8d8d8;--gray-300:#cdcdcd;--gray-350:silver;--gray-400:#b4b4b4;--gray-450:#a8a8a8;--gray-500:#9b9b9b;--gray-550:#818181;--gray-600:#676767;--gray-650:#545454;--gray-700:#424242;--gray-750:#2f2f2f;--gray-800:#212121;--gray-850:#1c1c1c;--gray-900:#171717;--gray-925:#121212;--gray-950:#0d0d0d;--gray-975:#0c0c0c;--gray-1000:#0b0b0b;--brand-purple:#ab68ff}@media (-webkit-min-device-pixel-ratio:2),(min--moz-device-pixel-ratio:2),(-o-min-device-pixel-ratio:2),(min-device-pixel-ratio:2),(min-resolution:192dpi),(min-resolution:2dppx){:root{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}}html,.light,.dark .light{--main-surface-background:#fffffff2;--message-surface:#e9e9e980;--composer-surface:var(--message-surface);--composer-blue-bg:#daeeff;--composer-blue-hover:#bddcf4;--composer-blue-hover-tint:#0084ff24;--composer-surface-primary:var(--main-surface-primary);--dot-color:var(--black);--text-primary:var(--gray-950);--icon-surface:13 13 13;--text-primary-inverse:var(--gray-100);--content-primary:#01172b;--content-secondary:#44505b;--text-secondary:#0009;--text-tertiary:#0000004a;--text-quaternary:#00000030;--tag-blue:#08f;--tag-blue-light:#0af;--text-error:#f93a37;--text-danger:var(--red-500);--text-placeholder:#000000b3;--surface-error:249 58 55;--border-xlight:#0000000d;--border-light:#0000001a;--border-medium:#00000026;--border-heavy:#0003;--border-xheavy:#00000040;--hint-text:#08f;--hint-bg:#b3dbff;--border-sharp:#0000000d;--icon-secondary:#676767;--main-surface-primary:var(--white);--main-surface-primary-inverse:var(--gray-800);--main-surface-secondary:var(--gray-50);--main-surface-secondary-selected:#0000001a;--main-surface-tertiary:var(--gray-100);--sidebar-surface-primary:var(--gray-50);--sidebar-surface-secondary:var(--gray-100);--sidebar-surface-tertiary:var(--gray-200);--sidebar-title-primary:#28282880;--sidebar-surface:#fcfcfc;--sidebar-body-primary:#0d0d0d;--sidebar-icon:#7d7d7d;--surface-hover:#00000012;--link:#2964aa;--link-hover:#749ac8;--selection:#007aff;--scrollbar-color:#0000001a;--scrollbar-color-hover:#0003}@supports (color:oklch(0.99 0 0)){html,.light,.dark .light{--sidebar-surface-floating-lightness:1;--sidebar-surface-floating-alpha:1;--sidebar-surface-pinned-lightness:.99;--sidebar-surface-pinned-alpha:1}}@media (prefers-reduced-transparency:reduce){html,.light,.dark .light{--message-surface:#f4f4f4}}.dark{--main-surface-background:#212121e6;--message-surface:#323232d9;--composer-blue-bg:#2a4a6d;--composer-blue-hover:#1a416a;--composer-blue-text:#48aaff;--composer-surface-primary:#303030;--dot-color:var(--white);--text-primary:var(--gray-100);--icon-surface:240 240 240;--text-primary-inverse:var(--gray-950);--text-secondary:#ffffffb3;--text-tertiary:#ffffff94;--text-quaternary:#ffffff69;--text-placeholder:#fffc;--content-primary:#f2f6fa;--content-secondary:#dbe2e8;--text-error:#f93a37;--border-xlight:#ffffff0d;--border-light:#ffffff1a;--border-medium:#ffffff26;--border-heavy:#fff3;--border-xheavy:#ffffff40;--border-sharp:#ffffff0d;--main-surface-primary:var(--gray-800);--main-surface-primary-inverse:var(--white);--main-surface-secondary:var(--gray-750);--main-surface-secondary-selected:#ffffff26;--main-surface-tertiary:var(--gray-700);--sidebar-surface-primary:var(--gray-900);--sidebar-surface-secondary:var(--gray-800);--sidebar-surface-tertiary:var(--gray-750);--sidebar-title-primary:#f0f0f080;--sidebar-surface:#2b2b2b;--sidebar-body-primary:#ededed;--sidebar-icon:#a4a4a4;--surface-hover:#ffffff26;--link:#7ab7ff;--link-hover:#5e83b3;--surface-error:249 58 55;--scrollbar-color:#ffffff1a;--scrollbar-color-hover:#fff3}@supports (color:oklch(0.99 0 0)){.dark{--sidebar-surface-floating-lightness:.3;--sidebar-surface-floating-alpha:1;--sidebar-surface-pinned-lightness:.29;--sidebar-surface-pinned-alpha:1}}@media (prefers-reduced-transparency:reduce){.dark{--message-surface:#2f2f2f}}.dark :not(.light).popover,.dark.popover,.popover .dark{--main-surface-primary:var(--gray-750);--main-surface-secondary:var(--gray-700);--main-surface-tertiary:var(--gray-600);--sidebar-surface-primary:var(--gray-750)}.popover,.light.popover,.light .popover,.dark .light.popover{--main-surface-primary:var(--white);--main-surface-secondary:var(--gray-100);--main-surface-tertiary:var(--gray-200);--sidebar-surface-primary:var(--white)}.dark .popover.sidebar{--main-surface-secondary:#393939!important}.light .canvas-open{--main-surface-primary:#f9f9f9;--message-surface:#eee}textarea:focus{box-shadow:none;border-color:inherit;outline:none}@supports (height:100cqh){:root{--cqh-full:100cqh;--cqw-full:100cqw}}@supports not (height:100cqh){:root{--cqh-full:100dvh;--cqw-full:100dvw}}[data-chat-theme=blue].mini-root,[data-chat-theme=blue].mini-root .dark{--interactive-bg-accent-default:var(--blue-theme-interactive-bg-accent-default)!important;--interactive-bg-accent-hover:var(--blue-theme-interactive-bg-accent-hover)!important;--interactive-bg-accent-muted-hover:var(--blue-theme-interactive-bg-accent-muted-hover)!important;--interactive-bg-accent-muted-context:var(--blue-theme-interactive-bg-accent-muted-context)!important;--interactive-bg-accent-press:var(--blue-theme-interactive-bg-accent-press)!important;--interactive-bg-accent-muted-press:var(--blue-theme-interactive-bg-accent-muted-press)!important;--interactive-bg-accent-inactive:var(--blue-theme-interactive-bg-accent-inactive)!important;--interactive-label-accent-default:var(--blue-theme-interactive-label-accent)!important;--interactive-label-accent-hover:var(--blue-theme-interactive-label-accent)!important;--interactive-label-accent-press:var(--blue-theme-interactive-label-accent)!important;--interactive-label-accent-inactive:var(--blue-theme-interactive-label-accent)!important;--interactive-label-accent-selected:var(--blue-theme-interactive-label-accent)!important}[data-chat-theme=green].mini-root,[data-chat-theme=green].mini-root .dark{--interactive-bg-accent-default:var(--green-theme-interactive-bg-accent-default)!important;--interactive-bg-accent-hover:var(--green-theme-interactive-bg-accent-hover)!important;--interactive-bg-accent-muted-hover:var(--green-theme-interactive-bg-accent-muted-hover)!important;--interactive-bg-accent-muted-context:var(--green-theme-interactive-bg-accent-muted-context)!important;--interactive-bg-accent-press:var(--green-theme-interactive-bg-accent-press)!important;--interactive-bg-accent-muted-press:var(--green-theme-interactive-bg-accent-muted-press)!important;--interactive-bg-accent-inactive:var(--green-theme-interactive-bg-accent-inactive)!important;--interactive-label-accent-default:var(--green-theme-interactive-label-accent)!important;--interactive-label-accent-hover:var(--green-theme-interactive-label-accent)!important;--interactive-label-accent-press:var(--green-theme-interactive-label-accent)!important;--interactive-label-accent-inactive:var(--green-theme-interactive-label-accent)!important;--interactive-label-accent-selected:var(--green-theme-interactive-label-accent)!important}[data-chat-theme=yellow].mini-root,[data-chat-theme=yellow].mini-root .dark{--interactive-bg-accent-default:var(--yellow-theme-interactive-bg-accent-default)!important;--interactive-bg-accent-hover:var(--yellow-theme-interactive-bg-accent-hover)!important;--interactive-bg-accent-muted-hover:var(--yellow-theme-interactive-bg-accent-muted-hover)!important;--interactive-bg-accent-muted-context:var(--yellow-theme-interactive-bg-accent-muted-context)!important;--interactive-bg-accent-press:var(--yellow-theme-interactive-bg-accent-press)!important;--interactive-bg-accent-muted-press:var(--yellow-theme-interactive-bg-accent-muted-press)!important;--interactive-bg-accent-inactive:var(--yellow-theme-interactive-bg-accent-inactive)!important;--interactive-label-accent-default:var(--yellow-theme-interactive-label-accent)!important;--interactive-label-accent-hover:var(--yellow-theme-interactive-label-accent)!important;--interactive-label-accent-press:var(--yellow-theme-interactive-label-accent)!important;--interactive-label-accent-inactive:var(--yellow-theme-interactive-label-accent)!important;--interactive-label-accent-selected:var(--yellow-theme-interactive-label-accent)!important}[data-chat-theme=purple].mini-root,[data-chat-theme=purple].mini-root .dark{--interactive-bg-accent-default:var(--purple-theme-interactive-bg-accent-default)!important;--interactive-bg-accent-hover:var(--purple-theme-interactive-bg-accent-hover)!important;--interactive-bg-accent-muted-hover:var(--purple-theme-interactive-bg-accent-muted-hover)!important;--interactive-bg-accent-muted-context:var(--purple-theme-interactive-bg-accent-muted-context)!important;--interactive-bg-accent-press:var(--purple-theme-interactive-bg-accent-press)!important;--interactive-bg-accent-muted-press:var(--purple-theme-interactive-bg-accent-muted-press)!important;--interactive-bg-accent-inactive:var(--purple-theme-interactive-bg-accent-inactive)!important;--interactive-label-accent-default:var(--purple-theme-interactive-label-accent)!important;--interactive-label-accent-hover:var(--purple-theme-interactive-label-accent)!important;--interactive-label-accent-press:var(--purple-theme-interactive-label-accent)!important;--interactive-label-accent-inactive:var(--purple-theme-interactive-label-accent)!important;--interactive-label-accent-selected:var(--purple-theme-interactive-label-accent)!important}[data-chat-theme=pink].mini-root,[data-chat-theme=pink].mini-root .dark{--interactive-bg-accent-default:var(--pink-theme-interactive-bg-accent-default)!important;--interactive-bg-accent-hover:var(--pink-theme-interactive-bg-accent-hover)!important;--interactive-bg-accent-muted-hover:var(--pink-theme-interactive-bg-accent-muted-hover)!important;--interactive-bg-accent-muted-context:var(--pink-theme-interactive-bg-accent-muted-context)!important;--interactive-bg-accent-press:var(--pink-theme-interactive-bg-accent-press)!important;--interactive-bg-accent-muted-press:var(--pink-theme-interactive-bg-accent-muted-press)!important;--interactive-bg-accent-inactive:var(--pink-theme-interactive-bg-accent-inactive)!important;--interactive-label-accent-default:var(--pink-theme-interactive-label-accent)!important;--interactive-label-accent-hover:var(--pink-theme-interactive-label-accent)!important;--interactive-label-accent-press:var(--pink-theme-interactive-label-accent)!important;--interactive-label-accent-inactive:var(--pink-theme-interactive-label-accent)!important;--interactive-label-accent-selected:var(--pink-theme-interactive-label-accent)!important}[data-chat-theme=orange].mini-root,[data-chat-theme=orange].mini-root .dark{--interactive-bg-accent-default:var(--orange-theme-interactive-bg-accent-default)!important;--interactive-bg-accent-hover:var(--orange-theme-interactive-bg-accent-hover)!important;--interactive-bg-accent-muted-hover:var(--orange-theme-interactive-bg-accent-muted-hover)!important;--interactive-bg-accent-muted-context:var(--orange-theme-interactive-bg-accent-muted-context)!important;--interactive-bg-accent-press:var(--orange-theme-interactive-bg-accent-press)!important;--interactive-bg-accent-muted-press:var(--orange-theme-interactive-bg-accent-muted-press)!important;--interactive-bg-accent-inactive:var(--orange-theme-interactive-bg-accent-inactive)!important;--interactive-label-accent-default:var(--orange-theme-interactive-label-accent)!important;--interactive-label-accent-hover:var(--orange-theme-interactive-label-accent)!important;--interactive-label-accent-press:var(--orange-theme-interactive-label-accent)!important;--interactive-label-accent-inactive:var(--orange-theme-interactive-label-accent)!important;--interactive-label-accent-selected:var(--orange-theme-interactive-label-accent)!important}[data-chat-theme=black].mini-root,[data-chat-theme=black].mini-root .dark{--interactive-bg-accent-default:var(--black-theme-interactive-bg-accent-default)!important;--interactive-bg-accent-hover:var(--black-theme-interactive-bg-accent-hover)!important;--interactive-bg-accent-muted-hover:var(--black-theme-interactive-bg-accent-muted-hover)!important;--interactive-bg-accent-muted-context:var(--black-theme-interactive-bg-accent-muted-context)!important;--interactive-bg-accent-press:var(--black-theme-interactive-bg-accent-press)!important;--interactive-bg-accent-muted-press:var(--black-theme-interactive-bg-accent-muted-press)!important;--interactive-bg-accent-inactive:var(--black-theme-interactive-bg-accent-inactive)!important;--interactive-label-accent-default:var(--black-theme-interactive-label-accent)!important;--interactive-label-accent-hover:var(--black-theme-interactive-label-accent)!important;--interactive-label-accent-press:var(--black-theme-interactive-label-accent)!important;--interactive-label-accent-inactive:var(--black-theme-interactive-label-accent)!important;--interactive-label-accent-selected:var(--black-theme-interactive-label-accent)!important}:root.mini-ua-root,:root.mini-ua-root.dark,:root.mini-ua-root.light .dark{--default-theme-user-msg-bg:var(--blue-theme-user-msg-bg);--default-theme-user-msg-text:var(--blue-theme-user-msg-text);--default-theme-submit-btn-bg:var(--blue-theme-submit-btn-bg);--default-theme-submit-btn-text:var(--blue-theme-submit-btn-text);--default-theme-secondary-btn-bg:var(--blue-theme-secondary-btn-bg);--default-theme-secondary-btn-text:var(--blue-theme-secondary-btn-text);--default-theme-user-selection-bg:var(--blue-theme-user-selection-bg);--default-theme-attribution-highlight-bg:var(--blue-theme-attribution-highlight-bg);--default-theme-entity-accent:var(--blue-theme-entity-accent)}body.mini-ua-root.mini[data-window-style=sidebar_view]{--sidechat-shadow:none!important}[type=text],input:where(:not([type])),[type=email],[type=url],[type=password],[type=number],[type=date],[type=datetime-local],[type=month],[type=search],[type=tel],[type=time],[type=week],[multiple],textarea,select{-webkit-appearance:none;appearance:none;border-color:var(--gray-500);--tw-shadow:0 0 transparent;background-color:#fff;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}:is([type=text],input:where(:not([type])),[type=email],[type=url],[type=password],[type=number],[type=date],[type=datetime-local],[type=month],[type=search],[type=tel],[type=time],[type=week],[multiple],textarea,select):focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:var(--blue-600);--tw-ring-offset-shadow:var(--tw-ring-inset)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:var(--blue-600);outline:2px solid #0000}input::placeholder,textarea::placeholder{color:var(--gray-500);opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em}::-webkit-date-and-time-value{text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-month-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-day-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-hour-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-minute-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-second-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-millisecond-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}select{-webkit-print-color-adjust:exact;print-color-adjust:exact;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='oklch(55.1%25 0.027 264.364)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem}[multiple],[size]:where(select:not([size="1"])){background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;print-color-adjust:unset;padding-right:.75rem}[type=checkbox],[type=radio]{-webkit-appearance:none;appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;width:1rem;height:1rem;color:var(--blue-600);border-color:var(--gray-500);--tw-shadow:0 0 transparent;background-color:#fff;background-origin:border-box;border-width:1px;flex-shrink:0;padding:0;display:inline-block}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:var(--blue-600);--tw-ring-offset-shadow:var(--tw-ring-inset)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);outline:2px solid #0000}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}[type=checkbox]:checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e")}@media (forced-colors:active){[type=checkbox]:checked{-webkit-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e")}@media (forced-colors:active){[type=radio]:checked{-webkit-appearance:auto;appearance:auto}}[type=checkbox]:checked:hover,[type=checkbox]:checked:focus,[type=radio]:checked:hover,[type=radio]:checked:focus{background-color:currentColor;border-color:#0000}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){[type=checkbox]:indeterminate{-webkit-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:hover,[type=checkbox]:indeterminate:focus{background-color:currentColor;border-color:#0000}[type=file]{background:unset;border-color:inherit;font-size:unset;line-height:inherit;border-width:0;border-radius:0;padding:0}[type=file]:focus{outline:1px solid buttontext;outline:1px auto -webkit-focus-ring-color}.text-mkt-h1,.text-mkt-h2,.text-mkt-h3,.text-mkt-h4,.text-mkt-h5,.text-mkt-h6{--tw-font-weight:500}}@layer components{table.mkt-bordered,table.mkt-bordered :where(tr){border-style:var(--tw-border-style);border-width:1px;border-color:var(--border-medium)}table.mkt-bordered :where(td,th){border-style:var(--tw-border-style);border-width:1px;border-color:var(--border-medium);padding:calc(var(--spacing)*2)!important}@property --mkt-scrollable-mask-left{syntax:"";inherits:false;initial-value:#000}@property --mkt-scrollable-mask-right{syntax:"";inherits:false;initial-value:#000}.mkt-style-scrollbars{scrollbar-gutter:stable}.mkt-style-scrollbars::-webkit-scrollbar{-webkit-appearance:none;background-color:#0000;width:16px}.mkt-style-scrollbars::-webkit-scrollbar-corner{background-color:#0000}.mkt-style-scrollbars::-webkit-scrollbar-thumb{background-clip:padding-box;background-color:var(--scrollbar-color);border:6px solid #0000;border-radius:9999px}.mkt-style-scrollbars::-webkit-scrollbar-thumb:hover{background-color:var(--scrollbar-color-hover)}.mkt-fade-scrollview-vertically{-webkit-mask-image:linear-gradient(90deg,#0000 0%,#fff 97%,#fff 100%),linear-gradient(#000 75%,#00000003 100%);mask-image:linear-gradient(90deg,#0000 0%,#fff 97%,#fff 100%),linear-gradient(#000 75%,#00000003 100%)}.mkt-scrollable{overscroll-behavior-x:contain;transition-property:--mkt-scrollable-mask-left,--mkt-scrollable-mask-right;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;-webkit-mask-image:linear-gradient(to right,var(--mkt-scrollable-mask-left)0%,black 2rem,black calc(100% - 2rem),var(--mkt-scrollable-mask-right)100%);-webkit-mask-image:linear-gradient(to right,var(--mkt-scrollable-mask-left)0%,black 2rem,black calc(100% - 2rem),var(--mkt-scrollable-mask-right)100%);-webkit-mask-image:linear-gradient(to right,var(--mkt-scrollable-mask-left)0%,black 2rem,black calc(100% - 2rem),var(--mkt-scrollable-mask-right)100%);-webkit-mask-image:linear-gradient(to right,var(--mkt-scrollable-mask-left)0%,black 2rem,black calc(100% - 2rem),var(--mkt-scrollable-mask-right)100%);mask-image:linear-gradient(to right,var(--mkt-scrollable-mask-left)0%,black 2rem,black calc(100% - 2rem),var(--mkt-scrollable-mask-right)100%);transition-property:--mkt-scrollable-mask-left,--mkt-scrollable-mask-right;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);overflow-x:auto;overflow-y:clip}.mkt-scrollable::-webkit-scrollbar{display:none}@property --top-fade{syntax:"";inherits:false;initial-value:0}@property --bottom-fade{syntax:"";inherits:false;initial-value:0}@property --edge-fade-distance{syntax:"";inherits:false;initial-value:.5lh}@keyframes edge-fade{0%{--top-fade:0}3%,to{--top-fade:var(--edge-fade-distance,.5lh)}0%,97%{--bottom-fade:var(--edge-fade-distance,.5lh)}to{--bottom-fade:0}}@supports (scroll-timeline:--scroll-fade){.vertical-scroll-fade-mask{-webkit-mask:linear-gradient(to bottom in oklch,#80808000,#cecece var(--top-fade)calc(100% - var(--bottom-fade)),#80808000);-webkit-mask:linear-gradient(to bottom in oklch,#80808000,#cecece var(--top-fade)calc(100% - var(--bottom-fade)),#80808000);-webkit-mask:linear-gradient(to bottom in oklch,#80808000,#cecece var(--top-fade)calc(100% - var(--bottom-fade)),#80808000);-webkit-mask:linear-gradient(to bottom in oklch,#80808000,#cecece var(--top-fade)calc(100% - var(--bottom-fade)),#80808000);mask:linear-gradient(to bottom in oklch,#80808000,#cecece var(--top-fade)calc(100% - var(--bottom-fade)),#80808000);scroll-timeline:--scroll-fade y;animation-name:edge-fade;animation-timing-function:linear;animation-fill-mode:both;animation-timeline:--scroll-fade}@supports (color:lab(0% 0 0)){.vertical-scroll-fade-mask{-webkit-mask:linear-gradient(to bottom in oklch,lab(53.6% 0 0/0),lab(82.6% .0000298023 -.0000119209) var(--top-fade)calc(100% - var(--bottom-fade)),lab(53.6% 0 0/0));-webkit-mask:linear-gradient(to bottom in oklch,lab(53.6% 0 0/0),lab(82.6% .0000298023 -.0000119209) var(--top-fade)calc(100% - var(--bottom-fade)),lab(53.6% 0 0/0));-webkit-mask:linear-gradient(to bottom in oklch,lab(53.6% 0 0/0),lab(82.6% .0000298023 -.0000119209) var(--top-fade)calc(100% - var(--bottom-fade)),lab(53.6% 0 0/0));-webkit-mask:linear-gradient(to bottom in oklch,lab(53.6% 0 0/0),lab(82.6% .0000298023 -.0000119209) var(--top-fade)calc(100% - var(--bottom-fade)),lab(53.6% 0 0/0));mask:linear-gradient(to bottom in oklch,lab(53.6% 0 0/0),lab(82.6% .0000298023 -.0000119209) var(--top-fade)calc(100% - var(--bottom-fade)),lab(53.6% 0 0/0))}}.horizontal-scroll-fade-mask{-webkit-mask:linear-gradient(to right in oklch,#80808000,#cecece var(--top-fade)calc(100% - var(--bottom-fade)),#80808000);-webkit-mask:linear-gradient(to right in oklch,#80808000,#cecece var(--top-fade)calc(100% - var(--bottom-fade)),#80808000);-webkit-mask:linear-gradient(to right in oklch,#80808000,#cecece var(--top-fade)calc(100% - var(--bottom-fade)),#80808000);-webkit-mask:linear-gradient(to right in oklch,#80808000,#cecece var(--top-fade)calc(100% - var(--bottom-fade)),#80808000);mask:linear-gradient(to right in oklch,#80808000,#cecece var(--top-fade)calc(100% - var(--bottom-fade)),#80808000);scroll-timeline:--scroll-fade x;animation-name:edge-fade;animation-timing-function:linear;animation-fill-mode:both;animation-timeline:--scroll-fade}@supports (color:lab(0% 0 0)){.horizontal-scroll-fade-mask{-webkit-mask:linear-gradient(to right in oklch,lab(53.6% 0 0/0),lab(82.6% .0000298023 -.0000119209) var(--top-fade)calc(100% - var(--bottom-fade)),lab(53.6% 0 0/0));-webkit-mask:linear-gradient(to right in oklch,lab(53.6% 0 0/0),lab(82.6% .0000298023 -.0000119209) var(--top-fade)calc(100% - var(--bottom-fade)),lab(53.6% 0 0/0));-webkit-mask:linear-gradient(to right in oklch,lab(53.6% 0 0/0),lab(82.6% .0000298023 -.0000119209) var(--top-fade)calc(100% - var(--bottom-fade)),lab(53.6% 0 0/0));-webkit-mask:linear-gradient(to right in oklch,lab(53.6% 0 0/0),lab(82.6% .0000298023 -.0000119209) var(--top-fade)calc(100% - var(--bottom-fade)),lab(53.6% 0 0/0));mask:linear-gradient(to right in oklch,lab(53.6% 0 0/0),lab(82.6% .0000298023 -.0000119209) var(--top-fade)calc(100% - var(--bottom-fade)),lab(53.6% 0 0/0))}}}.icon-lg{height:var(--icon-lg-size,1.5rem);width:var(--icon-lg-size,1.5rem);flex-grow:0;flex-shrink:0}.icon-lg>.icon{height:inherit;width:inherit}.icon-xl{height:calc(var(--spacing)*7);width:calc(var(--spacing)*7);flex-grow:0;flex-shrink:0}.icon-2xl{height:calc(var(--spacing)*8);width:calc(var(--spacing)*8);flex-grow:0;flex-shrink:0}.loading-shimmer-pure-text,.loading-shimmer{--shimmer-contrast:#ffffffbf;--shimmer-text-secondary:var(--text-secondary)}.dark .loading-shimmer-pure-text,.dark .loading-shimmer{--shimmer-contrast:#0009;--shimmer-text-secondary:var(--interactive-label-tertiary-hover)}.loading-shimmer-pure-text,.loading-shimmer{background:var(--shimmer-text-secondary)linear-gradient(to var(--end),var(--shimmer-text-secondary)0%,var(--shimmer-contrast)40%,var(--shimmer-contrast)60%,var(--shimmer-text-secondary)100%);background:var(--shimmer-text-secondary)-webkit-gradient(linear,100% 0,0 0,from(var(--shimmer-text-secondary)),color-stop(.4,var(--shimmer-contrast)),color-stop(.6,var(--shimmer-contrast)),to(var(--shimmer-text-secondary)));background-position:-100% 0;background-position:var(--is-ltr,-100%)var(--is-rtl,200%)top;text-fill-color:transparent;-webkit-text-fill-color:transparent;animation-duration:var(--cot-shimmer-duration);background-repeat:no-repeat;background-size:50% 200%;-webkit-background-clip:text;background-clip:text;animation-name:loading-shimmer;animation-iteration-count:infinite;animation-delay:0s;display:inline-block}@media (prefers-reduced-motion:reduce){.loading-shimmer-pure-text,.loading-shimmer{animation:none}}.loading-shimmer:hover{-webkit-text-fill-color:var(--text-primary);background:0 0}.loading-shimmer-pure-text-inverted{background:var(--text-primary)gradient(linear,100% 0,0 0,from(var(--text-primary)),color-stop(.5,var(--text-quaternary)),to(var(--text-primary)));background:var(--text-primary)-webkit-gradient(linear,100% 0,0 0,from(var(--text-primary)),color-stop(.5,var(--text-quaternary)),to(var(--text-primary)));background-position:-100% 0;background-position:var(--is-ltr,-100%)var(--is-rtl,200%)top;text-fill-color:transparent;-webkit-text-fill-color:transparent;background-repeat:no-repeat;background-size:50% 200%;-webkit-background-clip:text;background-clip:text;animation-name:loading-shimmer;animation-duration:3s;animation-iteration-count:infinite;animation-delay:.5s;display:inline-block}@media (prefers-reduced-motion:reduce){.loading-shimmer-pure-text-inverted{animation:none}}.button-glimmer{position:relative;overflow:hidden}.feh-border-mask{border-radius:inherit;pointer-events:none;box-sizing:border-box;z-index:1;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;width:100%;height:100%;padding:1px;position:absolute;top:0;left:0;-webkit-mask-image:linear-gradient(#000 0,#000 0),linear-gradient(#000 0,#000 0);mask-image:linear-gradient(#000 0,#000 0),linear-gradient(#000 0,#000 0);-webkit-mask-position:0 0,0 0;mask-position:0 0,0 0;-webkit-mask-size:auto,auto;mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.feh-border-glow{width:var(--feh-border-glow-size,500px);height:var(--feh-border-glow-size,500px);transform-origin:0 0;filter:drop-shadow(0 0 6px #7d5bffb3)drop-shadow(0 0 14px #7d5bff73);animation-name:var(--feh-border-glow-animation,rotateShine);animation-duration:var(--feh-border-glow-duration,3s);background:conic-gradient(#0000 0%,#7d5bfff2 10%,#7d5bff59,#0000 20%);animation-timing-function:linear;animation-iteration-count:infinite;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.dark .feh-border-glow{filter:drop-shadow(0 0 6px #aa96ffbf)drop-shadow(0 0 14px #aa96ff8c);background:conic-gradient(#0000 0%,#aa96fff2 10%,#aa96ff66,#0000 20%)}.pricing-page-primary-button-glimmer .feh-border-glow{background:conic-gradient(#0000 0%,#7d5bfff2 8%,#7d5bffad 18%,#7d5bff4d 28%,#0000 34%)}.dark .pricing-page-primary-button-glimmer .feh-border-glow{background:conic-gradient(#0000 0%,#aa96fff5 8%,#aa96ffb8 18%,#aa96ff57 28%,#0000 34%)}@media (prefers-reduced-motion:reduce){.feh-border-glow{animation:none}}.button-glimmer>button{z-index:2;position:relative}.button-glimmer:after{content:"";border-radius:inherit;opacity:0;pointer-events:none;background:linear-gradient(120deg,#0000 0%,#7d5bff14 15%,#7d5bff40 45%,#7d5bff1f 75%,#0000 100%);animation:3s linear infinite upgrade-button-gleam;position:absolute;top:0;bottom:0;left:0;right:0;transform:translate(-150%)skew(-18deg)}.dark .button-glimmer:after{background:linear-gradient(120deg,#0000 0%,#aa96ff1f 15%,#aa96ff52 45%,#aa96ff2e 75%,#0000 100%)}.button-text-shine{-webkit-text-fill-color:transparent;background-image:linear-gradient(120deg,currentColor 0%,currentColor 35%,#fff 50%,currentColor 65%,currentColor 100%);background-position:120% 0;background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:3s linear infinite upgrade-button-text-shine}@keyframes upgrade-button-text-shine{0%{background-position:120% 0}68%{background-position:120% 0}to{background-position:-100% 0}}@media (prefers-reduced-motion:reduce){.button-glimmer>button:after,.button-text-shine{animation:none}}.gizmo-shadow-stroke{position:relative}.gizmo-shadow-stroke:after{content:"";inset:calc(var(--spacing)*0);--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-radius:3.40282e38px;position:absolute}.dark .gizmo-shadow-stroke:after{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#fff3);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}:root{--menu-item-height:calc(var(--spacing)*9)}@media (pointer:coarse){:root{--menu-item-height:calc(var(--spacing)*10)}}.__menu-item{min-height:var(--menu-item-height);width:auto;scroll-margin:calc(var(--spacing)*1.5);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));-webkit-user-select:none;user-select:none;border-radius:10px;align-items:center;display:flex;position:relative}.__menu-item:not([data-orientation=horizontal]){margin-inline:calc(var(--spacing)*1.5)}.__menu-item:focus{--tw-outline-style:none;outline-style:none}.__menu-item[data-fill]{max-width:calc(100% - 3*var(--spacing))}.__menu-item[data-size=large]{min-height:calc(var(--spacing)*10)}.__menu-item[data-size=large]:dir(ltr){padding-left:calc(var(--spacing)*2)}.__menu-item[data-size=large]:dir(rtl){padding-right:calc(var(--spacing)*2)}@media (hover:hover){.__menu-item{cursor:pointer}}.__menu-item:where(.content-sheet *){padding-block:calc(var(--spacing)*2.25)}.__menu-item[data-sidebar-item]{border-bottom-style:var(--tw-border-style);background-clip:padding-box;border-color:#0000;border-bottom-width:1px}.__menu-item[data-has-submenu],.__menu-item:has(.trailing){justify-content:space-between;gap:calc(var(--spacing)*6)}:is(.__menu-item[data-has-submenu],.__menu-item:has(.trailing))[data-fill]{gap:calc(var(--spacing)*2)}.__menu-item:not([data-fill],[data-has-submenu],:has(.trailing),[data-orientation=horizontal]):dir(ltr){padding-right:calc(var(--spacing)*8)}.__menu-item:not([data-fill],[data-has-submenu],:has(.trailing),[data-orientation=horizontal]):dir(rtl){padding-left:calc(var(--spacing)*8)}.__menu-item{--menu-item-highlighted:#0000000a;--menu-item-active:#0000000f;--menu-item-open:#00000006}.dark .__menu-item{--menu-item-highlighted:var(--interactive-bg-secondary-hover);--menu-item-active:var(--interactive-bg-secondary-press);--menu-item-open:var(--interactive-bg-secondary-press)}.__menu-item[data-color=selected]{color:var(--interactive-label-accent-default)}.__menu-item[data-color=danger]{color:var(--text-status-error);--menu-item-highlighted:#e02e2a13;--menu-item-active:#e02e2a1f;--menu-item-open:#e02e2a0f}.dark .__menu-item[data-color=danger]{--menu-item-highlighted:#e02e2a23;--menu-item-active:#e02e2a2f;--menu-item-open:#e02e2a16}.__menu-item:where(:disabled,[data-disabled]){cursor:default;color:var(--text-tertiary)}.__menu-item:not(:disabled):not([data-disabled]):where(:has(:focus-visible),[data-state=open],:has([data-state=open])){background-color:var(--menu-item-open)}.__menu-item:not(:disabled):not([data-disabled]):focus,.__menu-item:not(:disabled):not([data-disabled])[data-highlighted],.__menu-item:not(:disabled):not([data-disabled])[data-state=active]{background-color:var(--menu-item-highlighted)}@media (hover:hover){.__menu-item:not(:disabled):not([data-disabled]).hoverable:hover{background-color:var(--menu-item-highlighted)}}.__menu-item:not(:disabled):not([data-disabled])[data-active],.__menu-item:not(:disabled):not([data-disabled]):active:not(:has([data-trailing-button]:hover)){background-color:var(--menu-item-active)}.__menu-item .trailing{min-width:calc(var(--spacing)*4);flex-shrink:0;justify-content:center;align-self:stretch;align-items:center;display:flex}.__menu-item .trailing-pair{grid-template-columns:max-content;align-self:stretch;place-items:center end;display:inline-grid}.__menu-item .trailing-pair>*{grid-row-start:1;grid-column-start:1;align-self:stretch;align-items:center;display:flex}@media (pointer:coarse){.__menu-item .trailing-pair>.trailing:not(.highlight){display:none}}@media (hover:hover){.__menu-item .trailing.highlight{opacity:0}.__menu-item[data-fill] .trailing.highlight{clip-path:inset(50%);white-space:nowrap;width:1px;height:1px;min-width:unset;border-width:0;margin:-1px;padding:0;position:absolute;overflow:hidden}.__menu-item:is([data-highlighted],[data-revealed],[data-state=active],:hover,:focus-visible,:has(:focus-visible),[data-state=open],:has([data-state=open])) .trailing-pair>.trailing:not(.highlight){visibility:hidden}.__menu-item:is([data-highlighted],[data-revealed],[data-state=active],:hover,:focus-visible,:has(:focus-visible),[data-state=open],:has([data-state=open])) .trailing.highlight{opacity:unset}.__menu-item:is([data-highlighted],[data-revealed],[data-state=active],:hover,:focus-visible,:has(:focus-visible),[data-state=open],:has([data-state=open]))[data-fill] .trailing.highlight{clip-path:none;white-space:normal;width:auto;height:auto;min-width:calc(var(--spacing)*4);margin:0;padding:0;position:static;overflow:visible}}.__menu-item-badge{border-style:var(--tw-border-style);border-width:1px;border-color:var(--border-heavy);--tw-leading:calc(var(--spacing)*3);font-size:8px;line-height:calc(var(--spacing)*3);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);text-transform:uppercase;border-radius:3.40282e38px;flex-shrink:0;padding:1px 5px}.__menu-item-badge:not(:is(:where(.group)[data-disabled] *)){color:var(--text-tertiary)}.__menu-item-badge:is(:where(.group)[data-disabled] *){opacity:.8}.shopping-cart-sidebar-item .__menu-item-badge{height:calc(var(--spacing)*5);min-width:calc(var(--spacing)*5);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing)*1.5);padding-block:calc(var(--spacing)*0);--tw-leading:1;--tw-font-weight:var(--font-weight-semibold);font-size:11px;line-height:1;font-weight:var(--font-weight-semibold);color:#fff;text-transform:none;background-color:#1a73e8;border-width:0;border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}button[role=combobox] .tier-badge{display:none}.__menu-item-trailing-btn,.__menu-item-trailing-lnk{pointer-events:auto;color:var(--text-primary)}:is(.__menu-item-trailing-btn,.__menu-item-trailing-lnk):disabled{pointer-events:none;color:var(--text-tertiary)}.__menu-item-trailing-btn,.__menu-item-trailing-lnk{isolation:isolate;min-height:calc(var(--spacing)*9);align-self:stretch;align-items:center;display:flex;position:relative}:is(.__menu-item-trailing-btn,.__menu-item-trailing-lnk):focus{--tw-outline-style:none;outline-style:none}@media (pointer:coarse){.__menu-item-trailing-btn,.__menu-item-trailing-lnk{min-height:calc(var(--spacing)*10)}}:is(:is(.__menu-item-trailing-btn,.__menu-item-trailing-lnk):is(html[data-focus-mode=keyboard] :focus-visible)>*){outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}.__menu-item-trailing-btn{isolation:isolate;margin-block:calc(var(--spacing)*-2)}.__menu-item-trailing-btn:dir(ltr){margin-left:calc(var(--spacing)*-1);margin-right:calc(var(--spacing)*-2.5);padding-left:calc(var(--spacing)*1);padding-right:calc(var(--spacing)*1.5);border-top-right-radius:10px;border-bottom-right-radius:10px}.__menu-item-trailing-btn:dir(rtl){margin-right:calc(var(--spacing)*-1);margin-left:calc(var(--spacing)*-2.5);padding-right:calc(var(--spacing)*1);padding-left:calc(var(--spacing)*1.5);border-top-left-radius:10px;border-bottom-left-radius:10px}[data-has-submenu] .__menu-item-trailing-btn:dir(ltr){margin-right:calc(var(--spacing)*-6.5);padding-right:calc(var(--spacing)*5.5)}[data-has-submenu] .__menu-item-trailing-btn:dir(rtl){margin-left:calc(var(--spacing)*-6.5);padding-left:calc(var(--spacing)*5.5)}@media (hover:hover){:is(.__menu-item-trailing-btn:is(:where(.group)[data-disabled] *):hover>*){background-color:var(--menu-item-highlighted)}}:is(.__menu-item-trailing-btn:is(:where(.group)[data-disabled] *):active:active>*){background-color:var(--menu-item-active)}.__menu-item-trailing-btn>*{border-radius:var(--radius-lg);padding:calc(var(--spacing)*1);justify-content:center;align-items:center;display:flex}@media (hover:hover){.__menu-item[data-sidebar-item] .__menu-item-trailing-btn:hover{color:var(--interactive-label-hover-secondary)}}.__menu-item[data-sidebar-item] .__menu-item-trailing-btn:focus-visible{color:var(--interactive-label-hover-secondary)}.__menu-item[data-sidebar-item] .__menu-item-trailing-btn:disabled,.__menu-item[data-sidebar-item] .__menu-item-trailing-btn:where([data-visually-disabled]){color:var(--interactive-label-inactive-secondary)}.__menu-item[data-sidebar-item] .__menu-item-trailing-btn:checked{color:var(--interactive-label-selected-secondary)}.__menu-item[data-sidebar-item] .__menu-item-trailing-btn:active{color:var(--interactive-label-press-secondary)}.__menu-item[data-sidebar-item] .__menu-item-trailing-btn{color:inherit}.__menu-item[data-sidebar-item] .__menu-item-trailing-btn[data-state=open]{color:var(--interactive-label-hover-secondary)}.__menu-item-trailing-lnk{isolation:isolate;margin-block:calc(var(--spacing)*-2)}@media (hover:hover){.__menu-item-trailing-lnk:hover{-webkit-text-decoration-line:underline;text-decoration-line:underline}}.__menu-label{margin-inline:calc(var(--spacing)*1.5);margin-block:calc(var(--spacing)*0);text-overflow:ellipsis;white-space:nowrap;padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal);color:var(--text-tertiary);-webkit-user-select:none;user-select:none;display:block;overflow:hidden}.__menu-label[data-no-spacing]{margin:calc(var(--spacing)*0);padding:calc(var(--spacing)*0)}}@layer utilities{.\@container\/col{container:col/inline-size}.\@container\/images-app-shell{container:images-app-shell/inline-size}.\@container\/images-promo-banner{container:images-promo-banner/inline-size}.\@container\/main{container:main/inline-size}.\@container\/preview-pane{container:preview-pane/inline-size}.\@container\/search-image{container:search-image/inline-size}.\@container\/sheet-content{container:sheet-content/inline-size}.\@container\/style-recents-modal{container:style-recents-modal/inline-size}.\@container,.\[container-type\:inline-size\]{container-type:inline-size}.\[container-type\:size\]{container-type:size}.btn{pointer-events:auto;min-height:calc(var(--spacing)*9);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing)*3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);border-width:1px;border-color:#0000;border-radius:3.40282e38px;flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.btn:disabled{cursor:not-allowed;opacity:.5}@media (pointer:coarse){.btn{min-height:calc(var(--spacing)*10)}}.btn:is(html[data-focus-mode=keyboard] :focus-visible){outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}.btn:active:not(:disabled){opacity:.8}.interactive-button-destructive{background-color:var(--interactive-button-bg-default-destructive);border-style:solid;border-width:1px;border-color:var(--interactive-button-border-default-destructive);color:var(--interactive-button-label-default-destructive)}.interactive-button-destructive .icon{color:var(--interactive-button-icon-default-destructive)}@media (hover:hover){.interactive-button-destructive:hover{background-color:var(--interactive-button-bg-hover-destructive);border-color:var(--interactive-button-border-hover-destructive);color:var(--interactive-button-label-hover-destructive)}.interactive-button-destructive:hover .icon{color:var(--interactive-button-icon-hover-destructive)}}.interactive-button-destructive:focus-visible{background-color:var(--interactive-button-bg-hover-destructive);border-color:var(--interactive-border-focus);color:var(--interactive-button-label-hover-destructive)}.interactive-button-destructive:focus-visible .icon{color:var(--interactive-button-icon-hover-destructive)}.interactive-button-destructive:disabled{background-color:var(--interactive-button-bg-inactive-destructive);border-color:var(--interactive-button-border-inactive-destructive);color:var(--interactive-button-label-inactive-destructive);opacity:.5;cursor:not-allowed;pointer-events:none}.interactive-button-destructive:disabled .icon{color:var(--interactive-button-icon-inactive-destructive)}.interactive-button-destructive:where([data-visually-disabled]){background-color:var(--interactive-button-bg-inactive-destructive);border-color:var(--interactive-button-border-inactive-destructive);color:var(--interactive-button-label-inactive-destructive);opacity:.5;cursor:not-allowed}.interactive-button-destructive:where([data-visually-disabled]) .icon{color:var(--interactive-button-icon-inactive-destructive)}.interactive-button-destructive:checked{background-color:var(--interactive-button-bg-selected-destructive);border-color:var(--interactive-button-border-selected-destructive);color:var(--interactive-button-label-selected-destructive)}.interactive-button-destructive:checked .icon{color:var(--interactive-button-icon-selected-destructive)}.interactive-button-destructive:active{background-color:var(--interactive-button-bg-press-destructive);border-color:var(--interactive-button-border-press-destructive);color:var(--interactive-button-label-press-destructive)}.interactive-button-destructive:active .icon{color:var(--interactive-button-icon-press-destructive)}.interactive-button-destructive[aria-disabled=true]{background-color:var(--interactive-button-bg-inactive-destructive);border-color:var(--interactive-button-border-inactive-destructive);color:var(--interactive-button-label-inactive-destructive);opacity:.5;cursor:not-allowed;pointer-events:none}.interactive-button-destructive[aria-disabled=true] .icon{color:var(--interactive-button-icon-inactive-destructive)}.interactive-button-primary{background-color:var(--interactive-button-bg-default-primary);border-style:solid;border-width:1px;border-color:var(--interactive-button-border-default-primary);color:var(--interactive-button-label-default-primary)}.interactive-button-primary .icon{color:var(--interactive-button-icon-default-primary)}@media (hover:hover){.interactive-button-primary:hover{background-color:var(--interactive-button-bg-hover-primary);border-color:var(--interactive-button-border-hover-primary);color:var(--interactive-button-label-hover-primary)}.interactive-button-primary:hover .icon{color:var(--interactive-button-icon-hover-primary)}}.interactive-button-primary:focus-visible{background-color:var(--interactive-button-bg-hover-primary);border-color:var(--interactive-border-focus);color:var(--interactive-button-label-hover-primary)}.interactive-button-primary:focus-visible .icon{color:var(--interactive-button-icon-hover-primary)}.interactive-button-primary:disabled{background-color:var(--interactive-button-bg-inactive-primary);border-color:var(--interactive-button-border-inactive-primary);color:var(--interactive-button-label-inactive-primary);opacity:.5;cursor:not-allowed;pointer-events:none}.interactive-button-primary:disabled .icon{color:var(--interactive-button-icon-inactive-primary)}.interactive-button-primary:where([data-visually-disabled]){background-color:var(--interactive-button-bg-inactive-primary);border-color:var(--interactive-button-border-inactive-primary);color:var(--interactive-button-label-inactive-primary);opacity:.5;cursor:not-allowed}.interactive-button-primary:where([data-visually-disabled]) .icon{color:var(--interactive-button-icon-inactive-primary)}.interactive-button-primary:checked{background-color:var(--interactive-button-bg-selected-primary);border-color:var(--interactive-button-border-selected-primary);color:var(--interactive-button-label-selected-primary)}.interactive-button-primary:checked .icon{color:var(--interactive-button-icon-selected-primary)}.interactive-button-primary:active{background-color:var(--interactive-button-bg-press-primary);border-color:var(--interactive-button-border-press-primary);color:var(--interactive-button-label-press-primary)}.interactive-button-primary:active .icon{color:var(--interactive-button-icon-press-primary)}.interactive-button-primary[aria-disabled=true]{background-color:var(--interactive-button-bg-inactive-primary);border-color:var(--interactive-button-border-inactive-primary);color:var(--interactive-button-label-inactive-primary);opacity:.5;cursor:not-allowed;pointer-events:none}.interactive-button-primary[aria-disabled=true] .icon{color:var(--interactive-button-icon-inactive-primary)}.interactive-button-sec-destructive{background-color:var(--interactive-button-bg-default-sec-destructive);border-style:solid;border-width:1px;border-color:var(--interactive-button-border-default-sec-destructive);color:var(--interactive-button-label-default-sec-destructive)}.interactive-button-sec-destructive .icon{color:var(--interactive-button-icon-default-sec-destructive)}@media (hover:hover){.interactive-button-sec-destructive:hover{background-color:var(--interactive-button-bg-hover-sec-destructive);border-color:var(--interactive-button-border-hover-sec-destructive);color:var(--interactive-button-label-hover-sec-destructive)}.interactive-button-sec-destructive:hover .icon{color:var(--interactive-button-icon-hover-sec-destructive)}}.interactive-button-sec-destructive:focus-visible{background-color:var(--interactive-button-bg-hover-sec-destructive);border-color:var(--interactive-border-focus);color:var(--interactive-button-label-hover-sec-destructive)}.interactive-button-sec-destructive:focus-visible .icon{color:var(--interactive-button-icon-hover-sec-destructive)}.interactive-button-sec-destructive:disabled{background-color:var(--interactive-button-bg-inactive-sec-destructive);border-color:var(--interactive-button-border-inactive-sec-destructive);color:var(--interactive-button-label-inactive-sec-destructive);opacity:.5;cursor:not-allowed;pointer-events:none}.interactive-button-sec-destructive:disabled .icon{color:var(--interactive-button-icon-inactive-sec-destructive)}.interactive-button-sec-destructive:where([data-visually-disabled]){background-color:var(--interactive-button-bg-inactive-sec-destructive);border-color:var(--interactive-button-border-inactive-sec-destructive);color:var(--interactive-button-label-inactive-sec-destructive);opacity:.5;cursor:not-allowed}.interactive-button-sec-destructive:where([data-visually-disabled]) .icon{color:var(--interactive-button-icon-inactive-sec-destructive)}.interactive-button-sec-destructive:checked{background-color:var(--interactive-button-bg-selected-sec-destructive);border-color:var(--interactive-button-border-selected-sec-destructive);color:var(--interactive-button-label-selected-sec-destructive)}.interactive-button-sec-destructive:checked .icon{color:var(--interactive-button-icon-selected-sec-destructive)}.interactive-button-sec-destructive:active{background-color:var(--interactive-button-bg-press-sec-destructive);border-color:var(--interactive-button-border-press-sec-destructive);color:var(--interactive-button-label-press-sec-destructive)}.interactive-button-sec-destructive:active .icon{color:var(--interactive-button-icon-press-sec-destructive)}.interactive-button-sec-destructive[aria-disabled=true]{background-color:var(--interactive-button-bg-inactive-sec-destructive);border-color:var(--interactive-button-border-inactive-sec-destructive);color:var(--interactive-button-label-inactive-sec-destructive);opacity:.5;cursor:not-allowed;pointer-events:none}.interactive-button-sec-destructive[aria-disabled=true] .icon{color:var(--interactive-button-icon-inactive-sec-destructive)}.interactive-button-secondary{background-color:var(--interactive-button-bg-default-secondary);border-style:solid;border-width:1px;border-color:var(--interactive-button-border-default-secondary);color:var(--interactive-button-label-default-secondary)}.interactive-button-secondary .icon{color:var(--interactive-button-icon-default-secondary)}@media (hover:hover){.interactive-button-secondary:hover{background-color:var(--interactive-button-bg-hover-secondary);border-color:var(--interactive-button-border-hover-secondary);color:var(--interactive-button-label-hover-secondary)}.interactive-button-secondary:hover .icon{color:var(--interactive-button-icon-hover-secondary)}}.interactive-button-secondary:focus-visible{background-color:var(--interactive-button-bg-hover-secondary);border-color:var(--interactive-border-focus);color:var(--interactive-button-label-hover-secondary)}.interactive-button-secondary:focus-visible .icon{color:var(--interactive-button-icon-hover-secondary)}.interactive-button-secondary:disabled{background-color:var(--interactive-button-bg-inactive-secondary);border-color:var(--interactive-button-border-inactive-secondary);color:var(--interactive-button-label-inactive-secondary);opacity:.5;cursor:not-allowed;pointer-events:none}.interactive-button-secondary:disabled .icon{color:var(--interactive-button-icon-inactive-secondary)}.interactive-button-secondary:where([data-visually-disabled]){background-color:var(--interactive-button-bg-inactive-secondary);border-color:var(--interactive-button-border-inactive-secondary);color:var(--interactive-button-label-inactive-secondary);opacity:.5;cursor:not-allowed}.interactive-button-secondary:where([data-visually-disabled]) .icon{color:var(--interactive-button-icon-inactive-secondary)}.interactive-button-secondary:checked{background-color:var(--interactive-button-bg-selected-secondary);border-color:var(--interactive-button-border-selected-secondary);color:var(--interactive-button-label-selected-secondary)}.interactive-button-secondary:checked .icon{color:var(--interactive-button-icon-selected-secondary)}.interactive-button-secondary:active{background-color:var(--interactive-button-bg-press-secondary);border-color:var(--interactive-button-border-press-secondary);color:var(--interactive-button-label-press-secondary)}.interactive-button-secondary:active .icon{color:var(--interactive-button-icon-press-secondary)}.interactive-button-secondary[aria-disabled=true]{background-color:var(--interactive-button-bg-inactive-secondary);border-color:var(--interactive-button-border-inactive-secondary);color:var(--interactive-button-label-inactive-secondary);opacity:.5;cursor:not-allowed;pointer-events:none}.interactive-button-secondary[aria-disabled=true] .icon{color:var(--interactive-button-icon-inactive-secondary)}.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.pointer-events-none\!{pointer-events:none!important}.\!visible{visibility:visible!important}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.visible\!{visibility:visible!important}.behavior-btn{--tap-padding:var(--tap-padding-mobile);position:relative}.behavior-btn:before{content:"";width:100%;height:100%;min-width:var(--tap-padding);min-height:var(--tap-padding);z-index:9999;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}@media (pointer:fine){.behavior-btn{--tap-padding:var(--tap-padding-pointer);position:relative}.behavior-btn:before{content:"";width:100%;height:100%;min-width:var(--tap-padding);min-height:var(--tap-padding);z-index:9999;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}}.behavior-btn:is(html[data-focus-mode=keyboard] :focus-visible){outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}.tap-padding-auto{--tap-padding:var(--tap-padding-mobile);position:relative}.tap-padding-auto:before{content:"";width:100%;height:100%;min-width:var(--tap-padding);min-height:var(--tap-padding);z-index:9999;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}@media (pointer:fine){.tap-padding-auto{--tap-padding:var(--tap-padding-pointer);position:relative}.tap-padding-auto:before{content:"";width:100%;height:100%;min-width:var(--tap-padding);min-height:var(--tap-padding);z-index:9999;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.sr-only\!{clip-path:inset(50%)!important;white-space:nowrap!important;border-width:0!important;width:1px!important;height:1px!important;margin:-1px!important;padding:0!important;position:absolute!important;overflow:hidden!important}.absolute{position:absolute}.absolute\!{position:absolute!important}.fixed{position:fixed}.relative{position:relative}.relative\!{position:relative!important}.static{position:static}.static\!{position:static!important}.sticky{position:-webkit-sticky;position:sticky}.inset-0{inset:calc(var(--spacing)*0)}.inset-5{inset:calc(var(--spacing)*5)}.inset-8{inset:calc(var(--spacing)*8)}.inset-\[-16px\]{top:-16px;bottom:-16px;left:-16px;right:-16px}.inset-\[-24px\]{top:-24px;bottom:-24px;left:-24px;right:-24px}.inset-\[5\%\]{top:5%;bottom:5%;left:5%;right:5%}.inset-\[11\.5px_10\.5px_11\.5px_12\.5px\]{top:11.5px;bottom:11.5px;left:12.5px;right:10.5px}.inset-\[20px\]{top:20px;bottom:20px;left:20px;right:20px}.inset-px{top:1px;bottom:1px;left:1px;right:1px}.inset-x-0{inset-inline:calc(var(--spacing)*0)}.inset-x-1\.5{inset-inline:calc(var(--spacing)*1.5)}.inset-x-2{inset-inline:calc(var(--spacing)*2)}.inset-x-4{inset-inline:calc(var(--spacing)*4)}.inset-x-6{inset-inline:calc(var(--spacing)*6)}.inset-x-7{inset-inline:calc(var(--spacing)*7)}.inset-x-12{inset-inline:calc(var(--spacing)*12)}.inset-x-px{left:1px;right:1px}.inset-y-0{inset-block:calc(var(--spacing)*0)}.inset-y-1{inset-block:calc(var(--spacing)*1)}.-start-0\.75:dir(ltr){left:calc(var(--spacing)*-.75)}.-start-0\.75:dir(rtl){right:calc(var(--spacing)*-.75)}.-start-1:dir(ltr){left:calc(var(--spacing)*-1)}.-start-1:dir(rtl){right:calc(var(--spacing)*-1)}.-start-2:dir(ltr){left:calc(var(--spacing)*-2)}.-start-2:dir(rtl){right:calc(var(--spacing)*-2)}.-start-3:dir(ltr){left:calc(var(--spacing)*-3)}.-start-3:dir(rtl){right:calc(var(--spacing)*-3)}.-start-4:dir(ltr){left:calc(var(--spacing)*-4)}.-start-4:dir(rtl){right:calc(var(--spacing)*-4)}.-start-7:dir(ltr){left:calc(var(--spacing)*-7)}.-start-7:dir(rtl){right:calc(var(--spacing)*-7)}.-start-8:dir(ltr){left:calc(var(--spacing)*-8)}.-start-8:dir(rtl){right:calc(var(--spacing)*-8)}.-start-12:dir(ltr){left:calc(var(--spacing)*-12)}.-start-12:dir(rtl){right:calc(var(--spacing)*-12)}.-start-96:dir(ltr){left:calc(var(--spacing)*-96)}.-start-96:dir(rtl){right:calc(var(--spacing)*-96)}.-start-\[10\%\]:dir(ltr){left:-10%}.-start-\[10\%\]:dir(rtl){right:-10%}.-start-px:dir(ltr){left:-1px}.-start-px:dir(rtl){right:-1px}.start-0:dir(ltr){left:calc(var(--spacing)*0)}.start-0:dir(rtl){right:calc(var(--spacing)*0)}.start-0\.5:dir(ltr){left:calc(var(--spacing)*.5)}.start-0\.5:dir(rtl){right:calc(var(--spacing)*.5)}.start-1:dir(ltr){left:calc(var(--spacing)*1)}.start-1:dir(rtl){right:calc(var(--spacing)*1)}.start-1\.5:dir(ltr){left:calc(var(--spacing)*1.5)}.start-1\.5:dir(rtl){right:calc(var(--spacing)*1.5)}.start-1\/2:dir(ltr){left:50%}.start-1\/2:dir(rtl){right:50%}.start-2:dir(ltr){left:calc(var(--spacing)*2)}.start-2:dir(rtl){right:calc(var(--spacing)*2)}.start-3:dir(ltr){left:calc(var(--spacing)*3)}.start-3:dir(rtl){right:calc(var(--spacing)*3)}.start-4:dir(ltr){left:calc(var(--spacing)*4)}.start-4:dir(rtl){right:calc(var(--spacing)*4)}.start-5:dir(ltr){left:calc(var(--spacing)*5)}.start-5:dir(rtl){right:calc(var(--spacing)*5)}.start-6:dir(ltr){left:calc(var(--spacing)*6)}.start-6:dir(rtl){right:calc(var(--spacing)*6)}.start-8:dir(ltr){left:calc(var(--spacing)*8)}.start-8:dir(rtl){right:calc(var(--spacing)*8)}.start-10:dir(ltr){left:calc(var(--spacing)*10)}.start-10:dir(rtl){right:calc(var(--spacing)*10)}.start-11:dir(ltr){left:calc(var(--spacing)*11)}.start-11:dir(rtl){right:calc(var(--spacing)*11)}.start-11\.5:dir(ltr){left:calc(var(--spacing)*11.5)}.start-11\.5:dir(rtl){right:calc(var(--spacing)*11.5)}.start-16:dir(ltr){left:calc(var(--spacing)*16)}.start-16:dir(rtl){right:calc(var(--spacing)*16)}.start-19\.5:dir(ltr){left:calc(var(--spacing)*19.5)}.start-19\.5:dir(rtl){right:calc(var(--spacing)*19.5)}.start-96:dir(ltr){left:calc(var(--spacing)*96)}.start-96:dir(rtl){right:calc(var(--spacing)*96)}.start-\[-2px\]:dir(ltr){left:-2px}.start-\[-2px\]:dir(rtl){right:-2px}.start-\[-4px\]:dir(ltr){left:-4px}.start-\[-4px\]:dir(rtl){right:-4px}.start-\[-8px\]:dir(ltr){left:-8px}.start-\[-8px\]:dir(rtl){right:-8px}.start-\[-10cqmax\]:dir(ltr){left:-10cqmax}.start-\[-10cqmax\]:dir(rtl){right:-10cqmax}.start-\[-30cqmax\]:dir(ltr){left:-30cqmax}.start-\[-30cqmax\]:dir(rtl){right:-30cqmax}.start-\[-150\%\]:dir(ltr){left:-150%}.start-\[-150\%\]:dir(rtl){right:-150%}.start-\[0\.81rem\]:dir(ltr){left:.81rem}.start-\[0\.81rem\]:dir(rtl){right:.81rem}.start-\[3\.25rem\]:dir(ltr){left:3.25rem}.start-\[3\.25rem\]:dir(rtl){right:3.25rem}.start-\[6\%\]:dir(ltr){left:6%}.start-\[6\%\]:dir(rtl){right:6%}.start-\[11\.4px\]:dir(ltr){left:11.4px}.start-\[11\.4px\]:dir(rtl){right:11.4px}.start-\[12\.6px\]:dir(ltr){left:12.6px}.start-\[12\.6px\]:dir(rtl){right:12.6px}.start-\[13cqmax\]:dir(ltr){left:13cqmax}.start-\[13cqmax\]:dir(rtl){right:13cqmax}.start-\[15px\]:dir(ltr){left:15px}.start-\[15px\]:dir(rtl){right:15px}.start-\[20cqmax\]:dir(ltr){left:20cqmax}.start-\[20cqmax\]:dir(rtl){right:20cqmax}.start-\[40cqmax\]:dir(ltr){left:40cqmax}.start-\[40cqmax\]:dir(rtl){right:40cqmax}.start-\[50\%\]:dir(ltr){left:50%}.start-\[50\%\]:dir(rtl){right:50%}.start-\[52px\]:dir(ltr){left:52px}.start-\[52px\]:dir(rtl){right:52px}.start-\[56px\]:dir(ltr){left:56px}.start-\[56px\]:dir(rtl){right:56px}.start-\[70px\]:dir(ltr){left:70px}.start-\[70px\]:dir(rtl){right:70px}.start-\[130px\]:dir(ltr){left:130px}.start-\[130px\]:dir(rtl){right:130px}.start-\[200px\]:dir(ltr){left:200px}.start-\[200px\]:dir(rtl){right:200px}.start-\[320px\]:dir(ltr){left:320px}.start-\[320px\]:dir(rtl){right:320px}.start-\[calc\(var\(--pricing-table-padding-inline\)\+var\(--pricing-table-label-min-width\)\)\]:dir(ltr){left:calc(var(--pricing-table-padding-inline) + var(--pricing-table-label-min-width))}.start-\[calc\(var\(--pricing-table-padding-inline\)\+var\(--pricing-table-label-min-width\)\)\]:dir(rtl){right:calc(var(--pricing-table-padding-inline) + var(--pricing-table-label-min-width))}.start-auto:dir(ltr){left:auto}.start-auto:dir(rtl){right:auto}.start-full:dir(ltr){left:100%}.start-full:dir(rtl){right:100%}.-end-0\.5:dir(ltr){right:calc(var(--spacing)*-.5)}.-end-0\.5:dir(rtl){left:calc(var(--spacing)*-.5)}.-end-1:dir(ltr){right:calc(var(--spacing)*-1)}.-end-1:dir(rtl){left:calc(var(--spacing)*-1)}.-end-2:dir(ltr){right:calc(var(--spacing)*-2)}.-end-2:dir(rtl){left:calc(var(--spacing)*-2)}.-end-3:dir(ltr){right:calc(var(--spacing)*-3)}.-end-3:dir(rtl){left:calc(var(--spacing)*-3)}.-end-4:dir(ltr){right:calc(var(--spacing)*-4)}.-end-4:dir(rtl){left:calc(var(--spacing)*-4)}.-end-5:dir(ltr){right:calc(var(--spacing)*-5)}.-end-5:dir(rtl){left:calc(var(--spacing)*-5)}.-end-\[0\.2rem\]:dir(ltr){right:-.2rem}.-end-\[0\.2rem\]:dir(rtl){left:-.2rem}.-end-\[10\%\]:dir(ltr){right:-10%}.-end-\[10\%\]:dir(rtl){left:-10%}.-end-\[20\%\]:dir(ltr){right:-20%}.-end-\[20\%\]:dir(rtl){left:-20%}.-end-\[60px\]:dir(ltr){right:-60px}.-end-\[60px\]:dir(rtl){left:-60px}.end-\(--thread-content-margin\):dir(ltr){right:var(--thread-content-margin)}.end-\(--thread-content-margin\):dir(rtl){left:var(--thread-content-margin)}.end-0:dir(ltr){right:calc(var(--spacing)*0)}.end-0:dir(rtl){left:calc(var(--spacing)*0)}.end-0\.5:dir(ltr){right:calc(var(--spacing)*.5)}.end-0\.5:dir(rtl){left:calc(var(--spacing)*.5)}.end-1:dir(ltr){right:calc(var(--spacing)*1)}.end-1:dir(rtl){left:calc(var(--spacing)*1)}.end-1\.5:dir(ltr){right:calc(var(--spacing)*1.5)}.end-1\.5:dir(rtl){left:calc(var(--spacing)*1.5)}.end-1\/2:dir(ltr){right:50%}.end-1\/2:dir(rtl){left:50%}.end-2:dir(ltr){right:calc(var(--spacing)*2)}.end-2:dir(rtl){left:calc(var(--spacing)*2)}.end-2\!:dir(ltr){right:calc(var(--spacing)*2)}.end-2\!:dir(rtl){left:calc(var(--spacing)*2)}.end-2\.5:dir(ltr){right:calc(var(--spacing)*2.5)}.end-2\.5:dir(rtl){left:calc(var(--spacing)*2.5)}.end-3:dir(ltr){right:calc(var(--spacing)*3)}.end-3:dir(rtl){left:calc(var(--spacing)*3)}.end-4:dir(ltr){right:calc(var(--spacing)*4)}.end-4:dir(rtl){left:calc(var(--spacing)*4)}.end-5:dir(ltr){right:calc(var(--spacing)*5)}.end-5:dir(rtl){left:calc(var(--spacing)*5)}.end-6:dir(ltr){right:calc(var(--spacing)*6)}.end-6:dir(rtl){left:calc(var(--spacing)*6)}.end-7:dir(ltr){right:calc(var(--spacing)*7)}.end-7:dir(rtl){left:calc(var(--spacing)*7)}.end-8:dir(ltr){right:calc(var(--spacing)*8)}.end-8:dir(rtl){left:calc(var(--spacing)*8)}.end-8\.5:dir(ltr){right:calc(var(--spacing)*8.5)}.end-8\.5:dir(rtl){left:calc(var(--spacing)*8.5)}.end-10:dir(ltr){right:calc(var(--spacing)*10)}.end-10:dir(rtl){left:calc(var(--spacing)*10)}.end-12:dir(ltr){right:calc(var(--spacing)*12)}.end-12:dir(rtl){left:calc(var(--spacing)*12)}.end-14:dir(ltr){right:calc(var(--spacing)*14)}.end-14:dir(rtl){left:calc(var(--spacing)*14)}.end-\[-1px\]:dir(ltr){right:-1px}.end-\[-1px\]:dir(rtl){left:-1px}.end-\[-4px\]:dir(ltr){right:-4px}.end-\[-4px\]:dir(rtl){left:-4px}.end-\[-8px\]:dir(ltr){right:-8px}.end-\[-8px\]:dir(rtl){left:-8px}.end-\[-135px\]:dir(ltr){right:-135px}.end-\[-135px\]:dir(rtl){left:-135px}.end-\[4\.8px\]:dir(ltr){right:4.8px}.end-\[4\.8px\]:dir(rtl){left:4.8px}.end-\[6\%\]:dir(ltr){right:6%}.end-\[6\%\]:dir(rtl){left:6%}.end-\[10\%\]:dir(ltr){right:10%}.end-\[10\%\]:dir(rtl){left:10%}.end-\[10px\]:dir(ltr){right:10px}.end-\[10px\]:dir(rtl){left:10px}.end-\[12px\]:dir(ltr){right:12px}.end-\[12px\]:dir(rtl){left:12px}.end-\[30px\]:dir(ltr){right:30px}.end-\[30px\]:dir(rtl){left:30px}.end-\[calc\(var\(--places-business-list-width\,0px\)\+1rem\)\]:dir(ltr){right:calc(var(--places-business-list-width,0px) + 1rem)}.end-\[calc\(var\(--places-business-list-width\,0px\)\+1rem\)\]:dir(rtl){left:calc(var(--places-business-list-width,0px) + 1rem)}.end-auto:dir(ltr){right:auto}.end-auto:dir(rtl){left:auto}.end-full:dir(ltr){right:100%}.end-full:dir(rtl){left:100%}.end-snc-1:dir(ltr){right:var(--snc-1)}.end-snc-1:dir(rtl){left:var(--snc-1)}.-top-0\.5{top:calc(var(--spacing)*-.5)}.-top-1{top:calc(var(--spacing)*-1)}.-top-1\.5{top:calc(var(--spacing)*-1.5)}.-top-2{top:calc(var(--spacing)*-2)}.-top-3{top:calc(var(--spacing)*-3)}.-top-3\!{top:calc(var(--spacing)*-3)!important}.-top-4{top:calc(var(--spacing)*-4)}.-top-5{top:calc(var(--spacing)*-5)}.-top-9{top:calc(var(--spacing)*-9)}.-top-32{top:calc(var(--spacing)*-32)}.-top-96{top:calc(var(--spacing)*-96)}.-top-\[5\%\]{top:-5%}.-top-\[10\%\]{top:-10%}.-top-\[30px\]{top:-30px}.-top-\[140px\]{top:-140px}.-top-px{top:-1px}.top-\(--header-height\){top:var(--header-height)}.top-\(--sticky-padding-top\){top:var(--sticky-padding-top)}.top-0{top:calc(var(--spacing)*0)}.top-0\!{top:calc(var(--spacing)*0)!important}.top-0\.5{top:calc(var(--spacing)*.5)}.top-1{top:calc(var(--spacing)*1)}.top-1\.5{top:calc(var(--spacing)*1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing)*2)}.top-2\.5{top:calc(var(--spacing)*2.5)}.top-3{top:calc(var(--spacing)*3)}.top-4{top:calc(var(--spacing)*4)}.top-5{top:calc(var(--spacing)*5)}.top-6{top:calc(var(--spacing)*6)}.top-7{top:calc(var(--spacing)*7)}.top-8{top:calc(var(--spacing)*8)}.top-9{top:calc(var(--spacing)*9)}.top-10{top:calc(var(--spacing)*10)}.top-12{top:calc(var(--spacing)*12)}.top-13{top:calc(var(--spacing)*13)}.top-14{top:calc(var(--spacing)*14)}.top-20{top:calc(var(--spacing)*20)}.top-22{top:calc(var(--spacing)*22)}.top-24{top:calc(var(--spacing)*24)}.top-46{top:calc(var(--spacing)*46)}.top-48{top:calc(var(--spacing)*48)}.top-\[-0\.094rem\]{top:-.094rem}.top-\[-2px\]{top:-2px}.top-\[-4px\]{top:-4px}.top-\[-6px\]{top:-6px}.top-\[-10cqmax\]{top:-10cqmax}.top-\[-10px\]{top:-10px}.top-\[-20cqmax\]{top:-20cqmax}.top-\[-38\.41\%\]{top:-38.41%}.top-\[-56px\]{top:-56px}.top-\[-150\%\]{top:-150%}.top-\[\.4em\]{top:.4em}.top-\[0\.1rem\]{top:.1rem}.top-\[0\.2rem\]{top:.2rem}.top-\[0\.5px\]{top:.5px}.top-\[0\.08em\]{top:.08em}.top-\[0\.55rem\]{top:.55rem}.top-\[0cqmax\]{top:0}.top-\[1px\]{top:1px}.top-\[3px\]{top:3px}.top-\[4px\]{top:4px}.top-\[8px\]{top:8px}.top-\[10\%\]{top:10%}.top-\[10px\]{top:10px}.top-\[11\.3px\]{top:11.3px}.top-\[12\.9px\]{top:12.9px}.top-\[20\.5px\]{top:20.5px}.top-\[20px\]{top:20px}.top-\[21\.5px\]{top:21.5px}.top-\[24px\]{top:24px}.top-\[30px\]{top:30px}.top-\[32\%\]{top:32%}.top-\[34\.5px\]{top:34.5px}.top-\[47px\]{top:47px}.top-\[50\%\]{top:50%}.top-\[70\%\]{top:70%}.top-\[75px\]{top:75px}.top-\[76px\]{top:76px}.top-\[78px\]{top:78px}.top-\[80\%\]{top:80%}.top-\[90\%\]{top:90%}.top-\[100\%\]{top:100%}.top-\[100px\]{top:100px}.top-\[200\%\]{top:200%}.top-\[233\.5px\]{top:233.5px}.top-\[233px\]{top:233px}.top-\[320px\]{top:320px}.top-\[calc\(1cap-1ex\)\]{top:calc(1cap - 1ex)}.top-\[calc\(100\%\+4px\)\]{top:calc(100% + 4px)}.top-\[calc\(100\%\+8px\)\]{top:calc(100% + 8px)}.top-\[calc\(var\(--header-height\)\+8px\)\]{top:calc(var(--header-height) + 8px)}.top-\[calc\(var\(--header-height\,48px\)\+8px\)\]{top:calc(var(--header-height,48px) + 8px)}.top-\[calc\(var\(--header-height\,48px\)\+48px\+8px\)\]{top:calc(var(--header-height,48px) + 48px + 8px)}.top-\[calc\(var\(--sticky-padding-top\)\+9\*var\(--spacing\)\)\]{top:calc(var(--sticky-padding-top) + 9*var(--spacing))}.top-full{top:100%}.\[right\:calc\(50\%\+var\(--true-content-width\)\/2\)\]{right:calc(50% + var(--true-content-width)/2)}.right-0\!{right:calc(var(--spacing)*0)!important}.right-\[8px\]{right:8px}.right-\[30px\]{right:30px}.right-auto{right:auto}.-bottom-\(--sticky-spacer\){bottom:calc(var(--sticky-spacer)*-1)}.-bottom-0\.5{bottom:calc(var(--spacing)*-.5)}.-bottom-1{bottom:calc(var(--spacing)*-1)}.-bottom-2{bottom:calc(var(--spacing)*-2)}.-bottom-3{bottom:calc(var(--spacing)*-3)}.-bottom-4{bottom:calc(var(--spacing)*-4)}.-bottom-5{bottom:calc(var(--spacing)*-5)}.-bottom-8{bottom:calc(var(--spacing)*-8)}.-bottom-10{bottom:calc(var(--spacing)*-10)}.-bottom-11{bottom:calc(var(--spacing)*-11)}.-bottom-\[0\.2rem\]{bottom:-.2rem}.-bottom-\[50px\]{bottom:-50px}.-bottom-px{bottom:-1px}.bottom-0{bottom:calc(var(--spacing)*0)}.bottom-0\!{bottom:calc(var(--spacing)*0)!important}.bottom-1{bottom:calc(var(--spacing)*1)}.bottom-1\.5{bottom:calc(var(--spacing)*1.5)}.bottom-2{bottom:calc(var(--spacing)*2)}.bottom-3{bottom:calc(var(--spacing)*3)}.bottom-4{bottom:calc(var(--spacing)*4)}.bottom-5{bottom:calc(var(--spacing)*5)}.bottom-6{bottom:calc(var(--spacing)*6)}.bottom-8{bottom:calc(var(--spacing)*8)}.bottom-9{bottom:calc(var(--spacing)*9)}.bottom-10{bottom:calc(var(--spacing)*10)}.bottom-12{bottom:calc(var(--spacing)*12)}.bottom-20{bottom:calc(var(--spacing)*20)}.bottom-32{bottom:calc(var(--spacing)*32)}.bottom-40{bottom:calc(var(--spacing)*40)}.bottom-\[-8px\]{bottom:-8px}.bottom-\[-20cqmax\]{bottom:-20cqmax}.bottom-\[-30cqmax\]{bottom:-30cqmax}.bottom-\[0cqmax\]{bottom:0}.bottom-\[8px\]{bottom:8px}.bottom-\[10\%\]{bottom:10%}.bottom-\[10px\]{bottom:10px}.bottom-\[20px\]{bottom:20px}.bottom-\[28\%\]{bottom:28%}.bottom-\[30px\]{bottom:30px}.bottom-\[64px\]{bottom:64px}.bottom-\[calc\(100\%\+6\*var\(--spacing\)\+var\(--thread-scroll-to-bottom-banner-offset\,0px\)\)\]{bottom:calc(100% + 6*var(--spacing) + var(--thread-scroll-to-bottom-banner-offset,0px))}.bottom-\[calc\(100\%\+14\*var\(--spacing\)\+var\(--thread-scroll-to-bottom-banner-offset\,0px\)\)\]{bottom:calc(100% + 14*var(--spacing) + var(--thread-scroll-to-bottom-banner-offset,0px))}.bottom-\[calc\(var\(--composer-overlap-px\)\+--spacing\(6\)\)\]{bottom:calc(var(--composer-overlap-px) + calc(var(--spacing)*6))}.bottom-\[initial\]{bottom:initial}.bottom-full{bottom:100%}.bottom-snc-1{bottom:var(--snc-1)}.left-0{left:calc(var(--spacing)*0)}.left-0\!{left:calc(var(--spacing)*0)!important}.left-1\/2{left:50%}.left-\[-6\.33\%\]{left:-6.33%}.left-\[8px\]{left:8px}.left-\[30px\]{left:30px}.left-\[50\%\]\!{left:50%!important}.isolate{isolation:isolate}.-z-1{z-index:calc(1*-1)}.-z-10{z-index:calc(10*-1)}.-z-20{z-index:calc(20*-1)}.-z-30{z-index:calc(30*-1)}.-z-\[1\]{z-index:calc(1*-1)}.z-0{z-index:0}.z-0\!{z-index:0!important}.z-1{z-index:1}.z-1\!{z-index:1!important}.z-2{z-index:2}.z-3{z-index:3}.z-5{z-index:5}.z-9{z-index:9}.z-10{z-index:10}.z-11{z-index:11}.z-20{z-index:20}.z-21{z-index:21}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-60{z-index:60}.z-61{z-index:61}.z-70{z-index:70}.z-100{z-index:100}.z-1000{z-index:1000}.z-\[-1\]{z-index:-1}.z-\[1\]{z-index:1}.z-\[2\]{z-index:2}.z-\[5\]{z-index:5}.z-\[10\]{z-index:10}.z-\[11\]{z-index:11}.z-\[50\]{z-index:50}.z-\[51\]{z-index:51}.z-\[60\]{z-index:60}.z-\[70\]{z-index:70}.z-\[80\]{z-index:80}.z-\[100\]{z-index:100}.z-\[120\]{z-index:120}.z-\[140\]{z-index:140}.z-\[200\]{z-index:200}.z-\[1000\]{z-index:1000}.z-\[1001\]{z-index:1001}.z-\[1100\]{z-index:1100}.z-\[2000\]{z-index:2000}.z-\[9999\]{z-index:9999}.z-\[10000\]{z-index:10000}.-order-1{order:calc(1*-1)}.order-1{order:1}.order-2{order:2}.order-4{order:4}.order-5{order:5}.order-first{order:-9999}.order-last{order:9999}.col-1{grid-column:1}.col-auto{grid-column:auto}.col-span-1{grid-column:span 1/span 1}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-10{grid-column:span 10/span 10}.col-span-12{grid-column:span 12/span 12}.col-span-full{grid-column:1/-1}.col-start-1{grid-column-start:1}.col-start-2{grid-column-start:2}.col-start-3{grid-column-start:3}.col-end-2{grid-column-end:2}.col-end-13{grid-column-end:13}.col-end-\[-1\]{grid-column-end:-1}.row-1{grid-row:1}.row-auto{grid-row:auto}.row-span-1{grid-row:span 1/span 1}.row-span-2{grid-row:span 2/span 2}.row-span-3{grid-row:span 3/span 3}.row-span-4{grid-row:span 4/span 4}.row-span-5{grid-row:span 5/span 5}.row-span-full{grid-row:1/-1}.row-start-1{grid-row-start:1}.row-start-2{grid-row-start:2}.row-end-2{grid-row-end:2}.float-end{float:inline-end}.float-left{float:left}.float-right{float:right}.float-start{float:inline-start}.clear-both{clear:both}.clear-end{clear:inline-end}.clear-left{clear:left}.clear-right{clear:right}.clear-start{clear:inline-start}.\!container{width:100%!important}@media (min-width:480px){.\!container{max-width:480px!important}}@media (min-width:40rem){.\!container{max-width:40rem!important}}@media (min-width:48rem){.\!container{max-width:48rem!important}}@media (min-width:64rem){.\!container{max-width:64rem!important}}@media (min-width:80rem){.\!container{max-width:80rem!important}}@media (min-width:96rem){.\!container{max-width:96rem!important}}.container{width:100%}@media (min-width:480px){.container{max-width:480px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);margin-top:1.2em;margin-bottom:1.2em;font-size:1.25em;line-height:1.6}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);font-weight:500;text-decoration:underline}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em;list-style-type:decimal}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:1.625em}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em;list-style-type:disc}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:1.625em}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-counters);font-weight:400}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.25em;font-weight:600}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-quotes);quotes:"“""”""‘""’";margin-top:1.6em;margin-bottom:1.6em;font-style:normal;font-weight:500}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){border-left-width:.25rem;border-left-color:var(--tw-prose-quote-borders);padding-left:1em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){border-right-width:.25rem;border-right-color:var(--tw-prose-quote-borders);padding-right:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:0;margin-bottom:.888889em;font-size:2.25em;font-weight:800;line-height:1.11111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:900}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:2em;margin-bottom:1em;font-size:1.5em;font-weight:700;line-height:1.33333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:800}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.6em;margin-bottom:.6em;font-size:1.25em;font-weight:600;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.5em;margin-bottom:.5em;font-weight:600;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em;display:block}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-kbd);box-shadow:0 0 0 1px rgb(var(--tw-prose-kbd-shadows)/10%),0 3px 0 rgb(var(--tw-prose-kbd-shadows)/10%);border-radius:.3125rem;padding-top:.1875em;padding-bottom:.1875em;font-family:inherit;font-size:.875em;font-weight:500}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:.375em;padding-right:.375em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:.375em;padding-right:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);background-color:var(--gray-100);border-radius:.25rem;padding:.15rem .3rem;font-size:.875em;font-weight:500}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:currentColor;background-color:#0000;border-radius:.375rem;margin:0;padding:0;font-size:.875em;font-weight:400;line-height:1.71429;overflow-x:auto}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:1.14286em;padding-right:1.14286em}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:1.14286em;padding-right:1.14286em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit;background-color:#0000;border-width:0;border-radius:0;padding:0}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){table-layout:auto;width:100%;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.71429}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);vertical-align:bottom;padding-bottom:.571429em;font-weight:600}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:.571429em;padding-right:.571429em}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:.571429em;padding-right:.571429em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);margin-top:.857143em;font-size:.875em;line-height:1.42857}.prose{--tw-prose-body:var(--text-primary);--tw-prose-headings:var(--text-primary);--tw-prose-lead:var(--text-primary);--tw-prose-links:var(--text-primary);--tw-prose-bold:var(--text-primary);--tw-prose-counters:var(--text-primary);--tw-prose-bullets:var(--text-primary);--tw-prose-hr:var(--border-xheavy);--tw-prose-quotes:var(--text-primary);--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:var(--text-secondary);--tw-prose-kbd:#101828;--tw-prose-kbd-shadows:NaN NaN NaN;--tw-prose-code:var(--text-primary);--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1e2939;--tw-prose-th-borders:#d1d5dc;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:var(--text-primary);--tw-prose-invert-headings:var(--text-primary);--tw-prose-invert-lead:var(--text-primary);--tw-prose-invert-links:var(--text-primary);--tw-prose-invert-bold:var(--text-primary);--tw-prose-invert-counters:var(--text-primary);--tw-prose-invert-bullets:var(--text-primary);--tw-prose-invert-hr:var(--border-xheavy);--tw-prose-invert-quotes:var(--text-primary);--tw-prose-invert-quote-borders:#364153;--tw-prose-invert-captions:var(--text-secondary);--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:255 255 255;--tw-prose-invert-code:var(--text-primary);--tw-prose-invert-pre-code:#d1d5dc;--tw-prose-invert-pre-bg:#00000080;--tw-prose-invert-th-borders:#4a5565;--tw-prose-invert-td-borders:#364153;font-size:1rem;line-height:1.75}@supports (color:lab(0% 0 0)){.prose{--tw-prose-quote-borders:lab(91.6229% -.159115 -2.26791);--tw-prose-kbd:lab(8.11897% .811279 -12.254);--tw-prose-pre-code:lab(91.6229% -.159115 -2.26791);--tw-prose-pre-bg:lab(16.1051% -1.18239 -11.7533);--tw-prose-th-borders:lab(85.1236% -.612259 -3.7138);--tw-prose-td-borders:lab(91.6229% -.159115 -2.26791);--tw-prose-invert-quote-borders:lab(27.1134% -.956401 -12.3224);--tw-prose-invert-pre-code:lab(85.1236% -.612259 -3.7138);--tw-prose-invert-th-borders:lab(35.6337% -1.58697 -10.8425);--tw-prose-invert-td-borders:lab(27.1134% -.956401 -12.3224)}}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr),.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:.375em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl),.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:1.625em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-right:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.571429em;padding-bottom:.571429em}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:.571429em;padding-right:.571429em}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:.571429em;padding-right:.571429em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:0}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-right:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.focus-outline-margin-default{--focus-outline-margin:var(--focus-outline-margin-default);margin:calc(var(--focus-outline-margin)*-1);padding:var(--focus-outline-margin)}.-m-0\.5{margin:calc(var(--spacing)*-.5)}.-m-1{margin:calc(var(--spacing)*-1)}.-m-1\!{margin:calc(var(--spacing)*-1)!important}.-m-1\.5{margin:calc(var(--spacing)*-1.5)}.-m-2{margin:calc(var(--spacing)*-2)}.-m-2\.5{margin:calc(var(--spacing)*-2.5)}.-m-3{margin:calc(var(--spacing)*-3)}.-m-4{margin:calc(var(--spacing)*-4)}.m-0{margin:calc(var(--spacing)*0)}.m-0\!{margin:calc(var(--spacing)*0)!important}.m-0\.5{margin:calc(var(--spacing)*.5)}.m-1{margin:calc(var(--spacing)*1)}.m-1\.5{margin:calc(var(--spacing)*1.5)}.m-2{margin:calc(var(--spacing)*2)}.m-3{margin:calc(var(--spacing)*3)}.m-4{margin:calc(var(--spacing)*4)}.m-5{margin:calc(var(--spacing)*5)}.m-6{margin:calc(var(--spacing)*6)}.m-8{margin:calc(var(--spacing)*8)}.m-1436{margin:calc(var(--spacing)*1436)}.m-2192{margin:calc(var(--spacing)*2192)}.m-2448{margin:calc(var(--spacing)*2448)}.m-\[-1px\]{margin:-1px}.m-\[3px\]{margin:3px}.m-auto{margin:auto}.-mx-\(--thread-content-margin\){margin-inline:calc(var(--thread-content-margin)*-1)}.-mx-0\.5{margin-inline:calc(var(--spacing)*-.5)}.-mx-1{margin-inline:calc(var(--spacing)*-1)}.-mx-1\.5{margin-inline:calc(var(--spacing)*-1.5)}.-mx-2{margin-inline:calc(var(--spacing)*-2)}.-mx-2\.5{margin-inline:calc(var(--spacing)*-2.5)}.-mx-3{margin-inline:calc(var(--spacing)*-3)}.-mx-4{margin-inline:calc(var(--spacing)*-4)}.-mx-4\.75\!{margin-inline:calc(var(--spacing)*-4.75)!important}.-mx-5{margin-inline:calc(var(--spacing)*-5)}.-mx-6{margin-inline:calc(var(--spacing)*-6)}.-mx-8{margin-inline:calc(var(--spacing)*-8)}.-mx-px{margin-left:-1px;margin-right:-1px}.mx-0{margin-inline:calc(var(--spacing)*0)}.mx-0\!{margin-inline:calc(var(--spacing)*0)!important}.mx-0\.5{margin-inline:calc(var(--spacing)*.5)}.mx-1{margin-inline:calc(var(--spacing)*1)}.mx-1\.5{margin-inline:calc(var(--spacing)*1.5)}.mx-2{margin-inline:calc(var(--spacing)*2)}.mx-3{margin-inline:calc(var(--spacing)*3)}.mx-3\!{margin-inline:calc(var(--spacing)*3)!important}.mx-3\.5{margin-inline:calc(var(--spacing)*3.5)}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-5{margin-inline:calc(var(--spacing)*5)}.mx-6{margin-inline:calc(var(--spacing)*6)}.mx-12{margin-inline:calc(var(--spacing)*12)}.mx-16{margin-inline:calc(var(--spacing)*16)}.mx-24{margin-inline:calc(var(--spacing)*24)}.mx-\[-1rem\]{margin-left:-1rem;margin-right:-1rem}.mx-\[-12px\]{margin-left:-12px;margin-right:-12px}.mx-\[-16px\]{margin-left:-16px;margin-right:-16px}.mx-\[2px\]{margin-left:2px;margin-right:2px}.mx-\[10px\]{margin-left:10px;margin-right:10px}.mx-\[32px\]{margin-left:32px;margin-right:32px}.mx-\[calc\(--spacing\(-2\)-1px\)\]{margin-inline:calc(calc(var(--spacing)*-2) - 1px)}.mx-auto{margin-left:auto;margin-right:auto}.mx-px{margin-left:1px;margin-right:1px}.mx-snc-results-padding{margin-inline:var(--snc-results-padding)}.-my-0\.5{margin-block:calc(var(--spacing)*-.5)}.-my-1{margin-block:calc(var(--spacing)*-1)}.-my-1\.5{margin-block:calc(var(--spacing)*-1.5)}.-my-2{margin-block:calc(var(--spacing)*-2)}.-my-2\.5{margin-block:calc(var(--spacing)*-2.5)}.-my-3{margin-block:calc(var(--spacing)*-3)}.-my-6{margin-block:calc(var(--spacing)*-6)}.-my-\[1px\]{margin-top:-1px;margin-bottom:-1px}.my-0{margin-block:calc(var(--spacing)*0)}.my-0\!{margin-block:calc(var(--spacing)*0)!important}.my-0\.5{margin-block:calc(var(--spacing)*.5)}.my-1{margin-block:calc(var(--spacing)*1)}.my-1\!{margin-block:calc(var(--spacing)*1)!important}.my-1\.5{margin-block:calc(var(--spacing)*1.5)}.my-2{margin-block:calc(var(--spacing)*2)}.my-2\.5{margin-block:calc(var(--spacing)*2.5)}.my-3{margin-block:calc(var(--spacing)*3)}.my-4{margin-block:calc(var(--spacing)*4)}.my-5{margin-block:calc(var(--spacing)*5)}.my-6{margin-block:calc(var(--spacing)*6)}.my-8{margin-block:calc(var(--spacing)*8)}.my-9{margin-block:calc(var(--spacing)*9)}.my-12{margin-block:calc(var(--spacing)*12)}.my-16{margin-block:calc(var(--spacing)*16)}.my-20\!{margin-block:calc(var(--spacing)*20)!important}.my-\[-0\.2rem\]{margin-top:-.2rem;margin-bottom:-.2rem}.my-\[32px\]{margin-top:32px;margin-bottom:32px}.my-\[calc\(\(1cap-1lh\)\*0\.3\)\]{margin-top:calc(.3cap - .3lh);margin-bottom:calc(.3cap - .3lh)}.my-auto{margin-top:auto;margin-bottom:auto}.my-px{margin-top:1px;margin-bottom:1px}.-ms-0\.5:dir(ltr){margin-left:calc(var(--spacing)*-.5)}.-ms-0\.5:dir(rtl){margin-right:calc(var(--spacing)*-.5)}.-ms-1:dir(ltr){margin-left:calc(var(--spacing)*-1)}.-ms-1:dir(rtl){margin-right:calc(var(--spacing)*-1)}.-ms-1\.5:dir(ltr){margin-left:calc(var(--spacing)*-1.5)}.-ms-1\.5:dir(rtl){margin-right:calc(var(--spacing)*-1.5)}.-ms-2:dir(ltr){margin-left:calc(var(--spacing)*-2)}.-ms-2:dir(rtl){margin-right:calc(var(--spacing)*-2)}.-ms-2\.5:dir(ltr){margin-left:calc(var(--spacing)*-2.5)}.-ms-2\.5:dir(rtl){margin-right:calc(var(--spacing)*-2.5)}.-ms-3:dir(ltr){margin-left:calc(var(--spacing)*-3)}.-ms-3:dir(rtl){margin-right:calc(var(--spacing)*-3)}.-ms-3\.5:dir(ltr){margin-left:calc(var(--spacing)*-3.5)}.-ms-3\.5:dir(rtl){margin-right:calc(var(--spacing)*-3.5)}.-ms-4:dir(ltr){margin-left:calc(var(--spacing)*-4)}.-ms-4:dir(rtl){margin-right:calc(var(--spacing)*-4)}.-ms-\[3px\]:dir(ltr){margin-left:-3px}.-ms-\[3px\]:dir(rtl){margin-right:-3px}.-ms-\[10px\]:dir(ltr){margin-left:-10px}.-ms-\[10px\]:dir(rtl){margin-right:-10px}.-ms-\[50vw\]:dir(ltr){margin-left:-50vw}.-ms-\[50vw\]:dir(rtl){margin-right:-50vw}.ms-0:dir(ltr){margin-left:calc(var(--spacing)*0)}.ms-0:dir(rtl){margin-right:calc(var(--spacing)*0)}.ms-0\!:dir(ltr){margin-left:calc(var(--spacing)*0)}.ms-0\!:dir(rtl){margin-right:calc(var(--spacing)*0)}.ms-0\.5:dir(ltr){margin-left:calc(var(--spacing)*.5)}.ms-0\.5:dir(rtl){margin-right:calc(var(--spacing)*.5)}.ms-1:dir(ltr){margin-left:calc(var(--spacing)*1)}.ms-1:dir(rtl){margin-right:calc(var(--spacing)*1)}.ms-1\.5:dir(ltr){margin-left:calc(var(--spacing)*1.5)}.ms-1\.5:dir(rtl){margin-right:calc(var(--spacing)*1.5)}.ms-2:dir(ltr){margin-left:calc(var(--spacing)*2)}.ms-2:dir(rtl){margin-right:calc(var(--spacing)*2)}.ms-2\.5:dir(ltr){margin-left:calc(var(--spacing)*2.5)}.ms-2\.5:dir(rtl){margin-right:calc(var(--spacing)*2.5)}.ms-3:dir(ltr){margin-left:calc(var(--spacing)*3)}.ms-3:dir(rtl){margin-right:calc(var(--spacing)*3)}.ms-4:dir(ltr){margin-left:calc(var(--spacing)*4)}.ms-4:dir(rtl){margin-right:calc(var(--spacing)*4)}.ms-4\.5:dir(ltr){margin-left:calc(var(--spacing)*4.5)}.ms-4\.5:dir(rtl){margin-right:calc(var(--spacing)*4.5)}.ms-5:dir(ltr){margin-left:calc(var(--spacing)*5)}.ms-5:dir(rtl){margin-right:calc(var(--spacing)*5)}.ms-6:dir(ltr){margin-left:calc(var(--spacing)*6)}.ms-6:dir(rtl){margin-right:calc(var(--spacing)*6)}.ms-7:dir(ltr){margin-left:calc(var(--spacing)*7)}.ms-7:dir(rtl){margin-right:calc(var(--spacing)*7)}.ms-8:dir(ltr){margin-left:calc(var(--spacing)*8)}.ms-8:dir(rtl){margin-right:calc(var(--spacing)*8)}.ms-10:dir(ltr){margin-left:calc(var(--spacing)*10)}.ms-10:dir(rtl){margin-right:calc(var(--spacing)*10)}.ms-14:dir(ltr){margin-left:calc(var(--spacing)*14)}.ms-14:dir(rtl){margin-right:calc(var(--spacing)*14)}.ms-24:dir(ltr){margin-left:calc(var(--spacing)*24)}.ms-24:dir(rtl){margin-right:calc(var(--spacing)*24)}.ms-\[-2px\]:dir(ltr){margin-left:-2px}.ms-\[-2px\]:dir(rtl){margin-right:-2px}.ms-\[-6px\]:dir(ltr){margin-left:-6px}.ms-\[-6px\]:dir(rtl){margin-right:-6px}.ms-\[-12px\]:dir(ltr){margin-left:-12px}.ms-\[-12px\]:dir(rtl){margin-right:-12px}.ms-\[-16px\]:dir(ltr){margin-left:-16px}.ms-\[-16px\]:dir(rtl){margin-right:-16px}.ms-\[-20px\]:dir(ltr){margin-left:-20px}.ms-\[-20px\]:dir(rtl){margin-right:-20px}.ms-\[0\.5px\]:dir(ltr){margin-left:.5px}.ms-\[0\.5px\]:dir(rtl){margin-right:.5px}.ms-\[2px\]:dir(ltr){margin-left:2px}.ms-\[2px\]:dir(rtl){margin-right:2px}.ms-\[3px\]:dir(ltr){margin-left:3px}.ms-\[3px\]:dir(rtl){margin-right:3px}.ms-\[5px\]:dir(ltr){margin-left:5px}.ms-\[5px\]:dir(rtl){margin-right:5px}.ms-\[6px\]:dir(ltr){margin-left:6px}.ms-\[6px\]:dir(rtl){margin-right:6px}.ms-\[11px\]:dir(ltr){margin-left:11px}.ms-\[11px\]:dir(rtl){margin-right:11px}.ms-auto:dir(ltr){margin-left:auto}.ms-auto:dir(rtl){margin-right:auto}.-me-0\.5:dir(ltr){margin-right:calc(var(--spacing)*-.5)}.-me-0\.5:dir(rtl){margin-left:calc(var(--spacing)*-.5)}.-me-0\.25:dir(ltr){margin-right:calc(var(--spacing)*-.25)}.-me-0\.25:dir(rtl){margin-left:calc(var(--spacing)*-.25)}.-me-1:dir(ltr){margin-right:calc(var(--spacing)*-1)}.-me-1:dir(rtl){margin-left:calc(var(--spacing)*-1)}.-me-1\.5:dir(ltr){margin-right:calc(var(--spacing)*-1.5)}.-me-1\.5:dir(rtl){margin-left:calc(var(--spacing)*-1.5)}.-me-2:dir(ltr){margin-right:calc(var(--spacing)*-2)}.-me-2:dir(rtl){margin-left:calc(var(--spacing)*-2)}.-me-2\.5:dir(ltr){margin-right:calc(var(--spacing)*-2.5)}.-me-2\.5:dir(rtl){margin-left:calc(var(--spacing)*-2.5)}.-me-3:dir(ltr){margin-right:calc(var(--spacing)*-3)}.-me-3:dir(rtl){margin-left:calc(var(--spacing)*-3)}.-me-4:dir(ltr){margin-right:calc(var(--spacing)*-4)}.-me-4:dir(rtl){margin-left:calc(var(--spacing)*-4)}.-me-5:dir(ltr){margin-right:calc(var(--spacing)*-5)}.-me-5:dir(rtl){margin-left:calc(var(--spacing)*-5)}.-me-14\.5:dir(ltr){margin-right:calc(var(--spacing)*-14.5)}.-me-14\.5:dir(rtl){margin-left:calc(var(--spacing)*-14.5)}.-me-\[10px\]:dir(ltr){margin-right:-10px}.-me-\[10px\]:dir(rtl){margin-left:-10px}.-me-\[50vw\]:dir(ltr){margin-right:-50vw}.-me-\[50vw\]:dir(rtl){margin-left:-50vw}.me-0:dir(ltr){margin-right:calc(var(--spacing)*0)}.me-0:dir(rtl){margin-left:calc(var(--spacing)*0)}.me-0\.5:dir(ltr){margin-right:calc(var(--spacing)*.5)}.me-0\.5:dir(rtl){margin-left:calc(var(--spacing)*.5)}.me-1:dir(ltr){margin-right:calc(var(--spacing)*1)}.me-1:dir(rtl){margin-left:calc(var(--spacing)*1)}.me-1\.5:dir(ltr){margin-right:calc(var(--spacing)*1.5)}.me-1\.5:dir(rtl){margin-left:calc(var(--spacing)*1.5)}.me-2:dir(ltr){margin-right:calc(var(--spacing)*2)}.me-2:dir(rtl){margin-left:calc(var(--spacing)*2)}.me-2\.5:dir(ltr){margin-right:calc(var(--spacing)*2.5)}.me-2\.5:dir(rtl){margin-left:calc(var(--spacing)*2.5)}.me-3:dir(ltr){margin-right:calc(var(--spacing)*3)}.me-3:dir(rtl){margin-left:calc(var(--spacing)*3)}.me-4:dir(ltr){margin-right:calc(var(--spacing)*4)}.me-4:dir(rtl){margin-left:calc(var(--spacing)*4)}.me-5:dir(ltr){margin-right:calc(var(--spacing)*5)}.me-5:dir(rtl){margin-left:calc(var(--spacing)*5)}.me-6:dir(ltr){margin-right:calc(var(--spacing)*6)}.me-6:dir(rtl){margin-left:calc(var(--spacing)*6)}.me-8:dir(ltr){margin-right:calc(var(--spacing)*8)}.me-8:dir(rtl){margin-left:calc(var(--spacing)*8)}.me-12:dir(ltr){margin-right:calc(var(--spacing)*12)}.me-12:dir(rtl){margin-left:calc(var(--spacing)*12)}.me-\[-4px\]:dir(ltr){margin-right:-4px}.me-\[-4px\]:dir(rtl){margin-left:-4px}.me-\[-8px\]:dir(ltr){margin-right:-8px}.me-\[-8px\]:dir(rtl){margin-left:-8px}.me-\[2px\]:dir(ltr){margin-right:2px}.me-\[2px\]:dir(rtl){margin-left:2px}.me-\[10px\]:dir(ltr){margin-right:10px}.me-\[10px\]:dir(rtl){margin-left:10px}.me-\[30px\]:dir(ltr){margin-right:30px}.me-\[30px\]:dir(rtl){margin-left:30px}.me-auto:dir(ltr){margin-right:auto}.me-auto:dir(rtl){margin-left:auto}.me-px:dir(ltr){margin-right:1px}.me-px:dir(rtl){margin-left:1px}.box-trim-0\.5{text-box:trim-both cap alphabetic}@supports not (text-box:trim-both cap alphabetic){.box-trim-0\.5:before{content:"";margin-bottom:calc(.5cap - .5lh);display:table}.box-trim-0\.5:after{content:"";margin-top:calc(.5cap - .5lh);display:table}}.box-trim-0\.25{text-box:trim-both cap alphabetic}@supports not (text-box:trim-both cap alphabetic){.box-trim-0\.25:before{content:"";margin-bottom:calc(.25cap - .25lh);display:table}.box-trim-0\.25:after{content:"";margin-top:calc(.75cap - .75lh);display:table}}.box-trim-text-0\.25{text-box:trim-both text}@supports not (text-box:trim-both text){.box-trim-text-0\.25:before{content:"";margin-bottom:calc(.25cap - .25lh);display:table}.box-trim-text-0\.25:after{content:"";margin-top:calc(.75cap - .75lh);display:table}}.prose-sm{font-size:.875rem;line-height:1.71429}.prose-sm :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em;margin-bottom:1.14286em}.prose-sm :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.888889em;margin-bottom:.888889em;font-size:1.28571em;line-height:1.55556}.prose-sm :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.33333em;margin-bottom:1.33333em}.prose-sm :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:1.11111em}.prose-sm :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:1.11111em}.prose-sm :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:.8em;font-size:2.14286em;line-height:1.2}.prose-sm :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.6em;margin-bottom:.8em;font-size:1.42857em;line-height:1.4}.prose-sm :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.55556em;margin-bottom:.444444em;font-size:1.28571em;line-height:1.55556}.prose-sm :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.42857em;margin-bottom:.571429em;line-height:1.42857}.prose-sm :where(img):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.71429em;margin-bottom:1.71429em}.prose-sm :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose-sm :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.71429em;margin-bottom:1.71429em}.prose-sm :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.3125rem;padding-top:.142857em;padding-bottom:.142857em;font-size:.857143em}.prose-sm :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:.357143em;padding-right:.357143em}.prose-sm :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:.357143em;padding-right:.357143em}.prose-sm :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.857143em}.prose-sm :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.9em}.prose-sm :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.888889em}.prose-sm :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.25rem;margin-top:1.66667em;margin-bottom:1.66667em;padding-top:.666667em;padding-bottom:.666667em;font-size:.857143em;line-height:1.66667}.prose-sm :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:1em;padding-right:1em}.prose-sm :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:1em;padding-right:1em}.prose-sm :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em;margin-bottom:1.14286em}.prose-sm :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr),.prose-sm :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:1.57143em}.prose-sm :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl),.prose-sm :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:1.57143em}.prose-sm :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.285714em;margin-bottom:.285714em}.prose-sm :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr),.prose-sm :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:.428571em}.prose-sm :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl),.prose-sm :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:.428571em}.prose-sm :where(.prose-sm>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.571429em;margin-bottom:.571429em}.prose-sm :where(.prose-sm>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em}.prose-sm :where(.prose-sm>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.14286em}.prose-sm :where(.prose-sm>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em}.prose-sm :where(.prose-sm>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.14286em}.prose-sm :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.571429em;margin-bottom:.571429em}.prose-sm :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em;margin-bottom:1.14286em}.prose-sm :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em}.prose-sm :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.285714em}.prose-sm :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:1.57143em}.prose-sm :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:1.57143em}.prose-sm :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2.85714em;margin-bottom:2.85714em}.prose-sm :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-sm :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.857143em;line-height:1.5}.prose-sm :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){padding-bottom:.666667em}.prose-sm :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:1em;padding-right:1em}.prose-sm :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:1em;padding-right:1em}.prose-sm :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:0}.prose-sm :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:0}.prose-sm :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-right:0}.prose-sm :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:0}.prose-sm :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.666667em;padding-bottom:.666667em}.prose-sm :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:1em;padding-right:1em}.prose-sm :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:1em;padding-right:1em}.prose-sm :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-left:0}.prose-sm :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-right:0}.prose-sm :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(ltr){padding-right:0}.prose-sm :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)):dir(rtl){padding-left:0}.prose-sm :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.71429em;margin-bottom:1.71429em}.prose-sm :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose-sm :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.666667em;font-size:.857143em;line-height:1.33333}.prose-sm :where(.prose-sm>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-sm :where(.prose-sm>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.offset-padding-top-4{--offset-padding:calc(var(--spacing)*4);margin-top:calc(var(--offset-padding)*-1);padding-top:var(--offset-padding)}.\!mt-0{margin-top:calc(var(--spacing)*0)!important}.\!mt-\[24px\]{margin-top:24px!important}.-mt-0\.5{margin-top:calc(var(--spacing)*-.5)}.-mt-0\.25{margin-top:calc(var(--spacing)*-.25)}.-mt-1{margin-top:calc(var(--spacing)*-1)}.-mt-1\.5{margin-top:calc(var(--spacing)*-1.5)}.-mt-2{margin-top:calc(var(--spacing)*-2)}.-mt-2\.5{margin-top:calc(var(--spacing)*-2.5)}.-mt-3{margin-top:calc(var(--spacing)*-3)}.-mt-4{margin-top:calc(var(--spacing)*-4)}.-mt-5{margin-top:calc(var(--spacing)*-5)}.-mt-6{margin-top:calc(var(--spacing)*-6)}.-mt-6\.5{margin-top:calc(var(--spacing)*-6.5)}.-mt-7{margin-top:calc(var(--spacing)*-7)}.-mt-10{margin-top:calc(var(--spacing)*-10)}.-mt-12{margin-top:calc(var(--spacing)*-12)}.-mt-14{margin-top:calc(var(--spacing)*-14)}.-mt-\[100px\]{margin-top:-100px}.mt-\(--sidebar-section-first-margin-top\){margin-top:var(--sidebar-section-first-margin-top)}.mt-0{margin-top:calc(var(--spacing)*0)}.mt-0\!{margin-top:calc(var(--spacing)*0)!important}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-0\.25{margin-top:calc(var(--spacing)*.25)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-1\.5{margin-top:calc(var(--spacing)*1.5)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-2\.5{margin-top:calc(var(--spacing)*2.5)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-5{margin-top:calc(var(--spacing)*5)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-7{margin-top:calc(var(--spacing)*7)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-9{margin-top:calc(var(--spacing)*9)}.mt-10{margin-top:calc(var(--spacing)*10)}.mt-10\!{margin-top:calc(var(--spacing)*10)!important}.mt-12{margin-top:calc(var(--spacing)*12)}.mt-13{margin-top:calc(var(--spacing)*13)}.mt-15{margin-top:calc(var(--spacing)*15)}.mt-16{margin-top:calc(var(--spacing)*16)}.mt-18{margin-top:calc(var(--spacing)*18)}.mt-22{margin-top:calc(var(--spacing)*22)}.mt-30{margin-top:calc(var(--spacing)*30)}.mt-36{margin-top:calc(var(--spacing)*36)}.mt-\[-1px\]{margin-top:-1px}.mt-\[-2px\]{margin-top:-2px}.mt-\[-4px\]{margin-top:-4px}.mt-\[-8px\]{margin-top:-8px}.mt-\[-10px\]{margin-top:-10px}.mt-\[-32px\]{margin-top:-32px}.mt-\[\.5px\],.mt-\[0\.5px\]{margin-top:.5px}.mt-\[0\.225rem\]{margin-top:.225rem}.mt-\[0\.425rem\]{margin-top:.425rem}.mt-\[0\.0625em\]{margin-top:.0625em}.mt-\[1px\]{margin-top:1px}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[5px\]{margin-top:5px}.mt-\[12px\]{margin-top:12px}.mt-\[14px\]{margin-top:14px}.mt-\[17px\]{margin-top:17px}.mt-\[22px\]{margin-top:22px}.mt-\[24\.5px\]{margin-top:24.5px}.mt-\[30px\]{margin-top:30px}.mt-\[36px\]{margin-top:36px}.mt-\[calc\(-1rem-3px\)\]{margin-top:calc(-1rem - 3px)}.mt-\[calc\(var\(--header-height\,52px\)\*-1\)\]{margin-top:calc(var(--header-height,52px)*-1)}.mt-\[calc\(var\(--threadFlyOut-leading-height\,53px\)\*-1\)\]{margin-top:calc(var(--threadFlyOut-leading-height,53px)*-1)}.mt-\[calc\(var\(--threadFlyOut-leading-height\,57px\)\*-1\)\]{margin-top:calc(var(--threadFlyOut-leading-height,57px)*-1)}.mt-\[calc\(var\(--threadFlyOut-leading-height\,var\(--header-height\)\)\*-1\)\]{margin-top:calc(var(--threadFlyOut-leading-height,var(--header-height))*-1)}.mt-\[env\(safe-area-inset-top\,0px\)\]{margin-top:env(safe-area-inset-top,0px)}.mt-\[min\(20svh\,150px\)\]{margin-top:min(20svh,150px)}.mt-\[var\(--screen-optical-compact-offset-amount\)\]{margin-top:var(--screen-optical-compact-offset-amount)}.mt-auto{margin-top:auto}.mt-px{margin-top:1px}.mt-snc-1{margin-top:var(--snc-1)}.mr-1{margin-right:calc(var(--spacing)*1)}.-mb-\(--composer-overlap-px\){margin-bottom:calc(var(--composer-overlap-px)*-1)}.-mb-0\.5{margin-bottom:calc(var(--spacing)*-.5)}.-mb-1{margin-bottom:calc(var(--spacing)*-1)}.-mb-1\.5{margin-bottom:calc(var(--spacing)*-1.5)}.-mb-2{margin-bottom:calc(var(--spacing)*-2)}.-mb-2\.5{margin-bottom:calc(var(--spacing)*-2.5)}.-mb-3{margin-bottom:calc(var(--spacing)*-3)}.-mb-4{margin-bottom:calc(var(--spacing)*-4)}.-mb-6{margin-bottom:calc(var(--spacing)*-6)}.-mb-9{margin-bottom:calc(var(--spacing)*-9)}.-mb-10{margin-bottom:calc(var(--spacing)*-10)}.-mb-\[1px\],.-mb-px{margin-bottom:-1px}.mb-0{margin-bottom:calc(var(--spacing)*0)}.mb-0\!{margin-bottom:calc(var(--spacing)*0)!important}.mb-0\.5{margin-bottom:calc(var(--spacing)*.5)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-1\.5{margin-bottom:calc(var(--spacing)*1.5)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-2\.5{margin-bottom:calc(var(--spacing)*2.5)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-3\.5{margin-bottom:calc(var(--spacing)*3.5)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-4\.5{margin-bottom:calc(var(--spacing)*4.5)}.mb-5{margin-bottom:calc(var(--spacing)*5)}.mb-5\!{margin-bottom:calc(var(--spacing)*5)!important}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-6\.5{margin-bottom:calc(var(--spacing)*6.5)}.mb-7{margin-bottom:calc(var(--spacing)*7)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.mb-9{margin-bottom:calc(var(--spacing)*9)}.mb-10{margin-bottom:calc(var(--spacing)*10)}.mb-11{margin-bottom:calc(var(--spacing)*11)}.mb-12{margin-bottom:calc(var(--spacing)*12)}.mb-30{margin-bottom:calc(var(--spacing)*30)}.mb-36{margin-bottom:calc(var(--spacing)*36)}.mb-64{margin-bottom:calc(var(--spacing)*64)}.mb-\[-1px\]{margin-bottom:-1px}.mb-\[-2px\]{margin-bottom:-2px}.mb-\[-5px\]{margin-bottom:-5px}.mb-\[-6px\]{margin-bottom:-6px}.mb-\[-12px\]{margin-bottom:-12px}.mb-\[0\.225rem\]{margin-bottom:.225rem}.mb-\[0\.425rem\]{margin-bottom:.425rem}.mb-\[0\.1875rem\]{margin-bottom:.1875rem}.mb-\[0\.3125rem\]{margin-bottom:.3125rem}.mb-\[1px\]{margin-bottom:1px}.mb-\[4px\]{margin-bottom:4px}.mb-\[8px\]{margin-bottom:8px}.mb-\[40px\]{margin-bottom:40px}.mb-\[60px\]{margin-bottom:60px}.mb-\[calc\(var\(--spacing\)\*1\)\]{margin-bottom:calc(var(--spacing)*1)}.mb-\[calc\(var\(--spacing\)\*2\)\]{margin-bottom:calc(var(--spacing)*2)}.mb-\[env\(safe-area-inset-bottom\,0px\)\]{margin-bottom:env(safe-area-inset-bottom,0px)}.mb-\[var\(--sidebar-collapsed-section-margin-bottom\)\]{margin-bottom:var(--sidebar-collapsed-section-margin-bottom)}.mb-\[var\(--sidebar-expanded-section-margin-bottom\)\]{margin-bottom:var(--sidebar-expanded-section-margin-bottom)}.mb-\[var\(--thread-component-gap\,1rem\)\]{margin-bottom:var(--thread-component-gap,1rem)}.mb-px{margin-bottom:1px}.mb-snc-1{margin-bottom:var(--snc-1)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-4{margin-left:calc(var(--spacing)*4)}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.chevron-marker:first-of-type>summary{list-style:none}.chevron-marker:first-of-type>summary:before{content:"";vertical-align:-.125em;background-color:var(--interactive-label-default-tertiary);width:16px;height:16px;display:inline-block;-webkit-mask:url(data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIxNiIKICBoZWlnaHQ9IjE2IgogIHZpZXdCb3g9IjAgMCAxNiAxNiIKICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogIGRhdGEtcnRsLWZsaXA9IiIKPgo8cGF0aAogICAgZD0iTTYuMDI5MjUgMy4wMjkyOUM2LjI1NjUyIDIuODAyMDIgNi42MDgwMyAyLjc3MzgyIDYuODY2MTYgMi45NDQzM0w2Ljk3MDY1IDMuMDI5MjlMMTEuNDcwNyA3LjUyOTI5QzExLjczMDQgNy43ODg5OSAxMS43MzA0IDguMjExIDExLjQ3MDcgOC40NzA3TDYuOTcwNjUgMTIuOTcwN0M2LjcxMDk1IDEzLjIzMDQgNi4yODg5NSAxMy4yMzA0IDYuMDI5MjUgMTIuOTcwN0M1Ljc2OTU1IDEyLjcxMSA1Ljc2OTU1IDEyLjI4OSA2LjAyOTI1IDEyLjAyOTNMMTAuMDU4NSA3Ljk5OTk5TDYuMDI5MjUgMy45NzA3TDUuOTQ0MjkgMy44NjYyQzUuNzczNzggMy42MDgwNyA1LjgwMTk4IDMuMjU2NTYgNi4wMjkyNSAzLjAyOTI5WiIKICAvPgo8L3N2Zz4=) 50%/contain no-repeat;mask:url(data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIxNiIKICBoZWlnaHQ9IjE2IgogIHZpZXdCb3g9IjAgMCAxNiAxNiIKICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogIGRhdGEtcnRsLWZsaXA9IiIKPgo8cGF0aAogICAgZD0iTTYuMDI5MjUgMy4wMjkyOUM2LjI1NjUyIDIuODAyMDIgNi42MDgwMyAyLjc3MzgyIDYuODY2MTYgMi45NDQzM0w2Ljk3MDY1IDMuMDI5MjlMMTEuNDcwNyA3LjUyOTI5QzExLjczMDQgNy43ODg5OSAxMS43MzA0IDguMjExIDExLjQ3MDcgOC40NzA3TDYuOTcwNjUgMTIuOTcwN0M2LjcxMDk1IDEzLjIzMDQgNi4yODg5NSAxMy4yMzA0IDYuMDI5MjUgMTIuOTcwN0M1Ljc2OTU1IDEyLjcxMSA1Ljc2OTU1IDEyLjI4OSA2LjAyOTI1IDEyLjAyOTNMMTAuMDU4NSA3Ljk5OTk5TDYuMDI5MjUgMy45NzA3TDUuOTQ0MjkgMy44NjYyQzUuNzczNzggMy42MDgwNyA1LjgwMTk4IDMuMjU2NTYgNi4wMjkyNSAzLjAyOTI5WiIKICAvPgo8L3N2Zz4=) 50%/contain no-repeat}@media (prefers-reduced-motion:no-preference){.chevron-marker:first-of-type>summary:before{transition:rotate var(--easing-spring-elegant-duration,.2s)var(--easing-spring-elegant,linear)}}.chevron-marker[open]:first-of-type>summary:before{rotate:90deg}.form-input{-webkit-appearance:none;appearance:none;border-color:var(--gray-500);--tw-shadow:0 0 transparent;background-color:#fff;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-input:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:var(--blue-600);--tw-ring-offset-shadow:var(--tw-ring-inset)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:var(--blue-600);outline:2px solid #0000}.form-input::placeholder{color:var(--gray-500);opacity:1}.form-input::-webkit-datetime-edit-fields-wrapper{padding:0}.form-input::-webkit-date-and-time-value{min-height:1.5em}.form-input::-webkit-date-and-time-value{text-align:inherit}.form-input::-webkit-datetime-edit{display:inline-flex}.form-input::-webkit-datetime-edit{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-month-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-day-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-hour-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-minute-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-second-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-millisecond-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}.grid-cols-auto{grid-template-columns:repeat(1,minmax(0,1fr));display:grid}.grid-cols-auto:has(>:nth-child(2):last-child){grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-auto:has(>:nth-child(3):last-child){grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-auto:has(>:nth-child(4):last-child){grid-template-columns:repeat(4,minmax(0,1fr))}.line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-4{-webkit-line-clamp:4;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-5{-webkit-line-clamp:5;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-6{-webkit-line-clamp:6;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-12{-webkit-line-clamp:12;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-none{-webkit-line-clamp:unset;-webkit-box-orient:horizontal;display:block;overflow:visible}.entity-underline{vertical-align:baseline;-webkit-text-decoration-line:underline;text-decoration-line:underline;-webkit-text-decoration-color:var(--text-tertiary);-webkit-text-decoration-color:var(--text-tertiary);-webkit-text-decoration-color:var(--text-tertiary);-webkit-text-decoration-color:var(--text-tertiary);text-decoration-color:var(--text-tertiary);text-underline-offset:2px;-webkit-text-decoration-style:dotted;text-decoration-style:dotted;text-decoration-thickness:1px;display:inline}@media (hover:hover){.entity-underline:hover{-webkit-text-decoration-color:inherit;-webkit-text-decoration-color:inherit;-webkit-text-decoration-color:inherit;-webkit-text-decoration-color:inherit;text-decoration-color:inherit}}.entity-underline{-webkit-text-decoration-skip-ink:auto;text-decoration-skip-ink:auto;text-underline-position:from-font}.\[display\:var\(--display-hidden-until-loaded\,block\)\]{display:var(--display-hidden-until-loaded,block)}.\[display\:var\(--display-hidden-until-loaded\,flex\)\]{display:var(--display-hidden-until-loaded,flex)}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.hidden\!{display:none!important}.inline{display:inline}.inline\!{display:inline!important}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.list-item{display:list-item}.table{display:table}.table\!{display:table!important}.table-caption{display:table-caption}.table-cell{display:table-cell}.aspect-2\/3{aspect-ratio:2/3}.aspect-3\/2{aspect-ratio:3/2}.aspect-4\/7{aspect-ratio:4/7}.aspect-7\/4{aspect-ratio:7/4}.aspect-16\/9{aspect-ratio:16/9}.aspect-\[1\.45\]{aspect-ratio:1.45}.aspect-\[2\.5\/1\]{aspect-ratio:2.5}.aspect-\[2\/1\]{aspect-ratio:2}.aspect-\[3\/1\]{aspect-ratio:3}.aspect-\[4\/3\]{aspect-ratio:4/3}.aspect-\[4\/5\]{aspect-ratio:4/5}.aspect-\[5\/4\]{aspect-ratio:5/4}.aspect-\[9\/16\]{aspect-ratio:9/16}.aspect-\[13\/16\]{aspect-ratio:13/16}.aspect-\[16\/9\]{aspect-ratio:16/9}.aspect-\[45\/32\]{aspect-ratio:45/32}.aspect-\[360\/272\]{aspect-ratio:360/272}.aspect-\[614\/493\]{aspect-ratio:614/493}.aspect-\[752\/244\]{aspect-ratio:752/244}.aspect-\[1024\/697\]{aspect-ratio:1024/697}.aspect-\[1024\/698\]{aspect-ratio:1024/698}.aspect-\[1024\/755\]{aspect-ratio:1024/755}.aspect-\[1024\/764\]{aspect-ratio:1024/764}.aspect-\[1024\/768\]{aspect-ratio:1024/768}.aspect-\[1024\/777\]{aspect-ratio:1024/777}.aspect-\[1200\/630\]{aspect-ratio:1200/630}.aspect-auto{aspect-ratio:auto}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.icon{height:calc(var(--spacing)*5);width:calc(var(--spacing)*5);flex-grow:0;flex-shrink:0}.icon\!{height:calc(var(--spacing)*5)!important;width:calc(var(--spacing)*5)!important;flex-grow:0!important;flex-shrink:0!important}.icon-sm{height:calc(var(--spacing)*4);width:calc(var(--spacing)*4);flex-grow:0;flex-shrink:0}.icon-xs{height:calc(var(--spacing)*3);width:calc(var(--spacing)*3);flex-grow:0;flex-shrink:0}.size-1\.5{width:calc(var(--spacing)*1.5);height:calc(var(--spacing)*1.5)}.size-3{width:calc(var(--spacing)*3);height:calc(var(--spacing)*3)}.size-3\.5{width:calc(var(--spacing)*3.5);height:calc(var(--spacing)*3.5)}.size-3\.5\!{width:calc(var(--spacing)*3.5)!important;height:calc(var(--spacing)*3.5)!important}.size-4{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.size-4\.5{width:calc(var(--spacing)*4.5);height:calc(var(--spacing)*4.5)}.size-5{width:calc(var(--spacing)*5);height:calc(var(--spacing)*5)}.size-5\.5{width:calc(var(--spacing)*5.5);height:calc(var(--spacing)*5.5)}.size-6{width:calc(var(--spacing)*6);height:calc(var(--spacing)*6)}.size-6\.5{width:calc(var(--spacing)*6.5);height:calc(var(--spacing)*6.5)}.size-7{width:calc(var(--spacing)*7);height:calc(var(--spacing)*7)}.size-8{width:calc(var(--spacing)*8);height:calc(var(--spacing)*8)}.size-8\!{width:calc(var(--spacing)*8)!important;height:calc(var(--spacing)*8)!important}.size-9{width:calc(var(--spacing)*9);height:calc(var(--spacing)*9)}.size-10{width:calc(var(--spacing)*10);height:calc(var(--spacing)*10)}.size-11{width:calc(var(--spacing)*11);height:calc(var(--spacing)*11)}.size-12{width:calc(var(--spacing)*12);height:calc(var(--spacing)*12)}.size-15{width:calc(var(--spacing)*15);height:calc(var(--spacing)*15)}.size-16{width:calc(var(--spacing)*16);height:calc(var(--spacing)*16)}.size-\[1lh\]{width:1lh;height:1lh}.size-\[10px\]{width:10px;height:10px}.size-\[18px\]{width:18px;height:18px}.size-\[76px\]{width:76px;height:76px}.size-full{width:100%;height:100%}.size-min{width:-webkit-min-content;width:min-content;height:-webkit-min-content;height:min-content}.size-px{width:1px;height:1px}.\!h-7{height:calc(var(--spacing)*7)!important}.\!h-8{height:calc(var(--spacing)*8)!important}.\!h-\[76px\]{height:76px!important}.\!h-full{height:100%!important}.h-\(--composer-container-height\,100\%\){height:var(--composer-container-height,100%)}.h-\(--control-size-lg\){height:var(--control-size-lg)}.h-\(--header-height\){height:var(--header-height)}.h-\(--sticky-spacer\){height:var(--sticky-spacer)}.h-0{height:calc(var(--spacing)*0)}.h-0\.5{height:calc(var(--spacing)*.5)}.h-1{height:calc(var(--spacing)*1)}.h-1\.5{height:calc(var(--spacing)*1.5)}.h-1\.75{height:calc(var(--spacing)*1.75)}.h-1\/3{height:33.3333%}.h-1\/4{height:25%}.h-2{height:calc(var(--spacing)*2)}.h-2\.5{height:calc(var(--spacing)*2.5)}.h-2\.75{height:calc(var(--spacing)*2.75)}.h-2\/3{height:66.6667%}.h-3{height:calc(var(--spacing)*3)}.h-3\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-4\.5{height:calc(var(--spacing)*4.5)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-6\!{height:calc(var(--spacing)*6)!important}.h-6\.5{height:calc(var(--spacing)*6.5)}.h-7{height:calc(var(--spacing)*7)}.h-7\!{height:calc(var(--spacing)*7)!important}.h-8{height:calc(var(--spacing)*8)}.h-8\!{height:calc(var(--spacing)*8)!important}.h-9{height:calc(var(--spacing)*9)}.h-9\!{height:calc(var(--spacing)*9)!important}.h-9\.5{height:calc(var(--spacing)*9.5)}.h-10{height:calc(var(--spacing)*10)}.h-11{height:calc(var(--spacing)*11)}.h-11\.5{height:calc(var(--spacing)*11.5)}.h-12{height:calc(var(--spacing)*12)}.h-12\!{height:calc(var(--spacing)*12)!important}.h-13{height:calc(var(--spacing)*13)}.h-13\!{height:calc(var(--spacing)*13)!important}.h-14{height:calc(var(--spacing)*14)}.h-15{height:calc(var(--spacing)*15)}.h-16{height:calc(var(--spacing)*16)}.h-18{height:calc(var(--spacing)*18)}.h-18\!{height:calc(var(--spacing)*18)!important}.h-20{height:calc(var(--spacing)*20)}.h-20\!{height:calc(var(--spacing)*20)!important}.h-24{height:calc(var(--spacing)*24)}.h-28{height:calc(var(--spacing)*28)}.h-30{height:calc(var(--spacing)*30)}.h-32{height:calc(var(--spacing)*32)}.h-34{height:calc(var(--spacing)*34)}.h-36{height:calc(var(--spacing)*36)}.h-40{height:calc(var(--spacing)*40)}.h-42{height:calc(var(--spacing)*42)}.h-44{height:calc(var(--spacing)*44)}.h-45{height:calc(var(--spacing)*45)}.h-48{height:calc(var(--spacing)*48)}.h-50{height:calc(var(--spacing)*50)}.h-56{height:calc(var(--spacing)*56)}.h-60{height:calc(var(--spacing)*60)}.h-64{height:calc(var(--spacing)*64)}.h-72{height:calc(var(--spacing)*72)}.h-74{height:calc(var(--spacing)*74)}.h-84{height:calc(var(--spacing)*84)}.h-88{height:calc(var(--spacing)*88)}.h-92{height:calc(var(--spacing)*92)}.h-96{height:calc(var(--spacing)*96)}.h-105{height:calc(var(--spacing)*105)}.h-120{height:calc(var(--spacing)*120)}.h-\[0\.6rem\]{height:.6rem}.h-\[0\.75em\]{height:.75em}.h-\[0\.75rem\]{height:.75rem}.h-\[1\.5px\]{height:1.5px}.h-\[1em\]{height:1em}.h-\[1lh\]\!{height:1lh!important}.h-\[1px\]{height:1px}.h-\[2px\]{height:2px}.h-\[3px\]{height:3px}.h-\[4px\]{height:4px}.h-\[6\.5px\]{height:6.5px}.h-\[6px\]{height:6px}.h-\[8px\]{height:8px}.h-\[9px\]{height:9px}.h-\[11px\]{height:11px}.h-\[12px\]{height:12px}.h-\[13\.333px\]{height:13.333px}.h-\[14px\]{height:14px}.h-\[15\.83px\]{height:15.83px}.h-\[15dvh\]{height:15dvh}.h-\[15px\]{height:15px}.h-\[16px\]{height:16px}.h-\[17px\]{height:17px}.h-\[18px\]{height:18px}.h-\[18px\]\!{height:18px!important}.h-\[20px\]{height:20px}.h-\[21px\]{height:21px}.h-\[22px\]{height:22px}.h-\[22rem\]{height:22rem}.h-\[23px\]{height:23px}.h-\[24pt\]{height:24pt}.h-\[24px\]{height:24px}.h-\[24rem\]{height:24rem}.h-\[25px\]{height:25px}.h-\[26px\]{height:26px}.h-\[27px\]{height:27px}.h-\[28px\]{height:28px}.h-\[30px\]{height:30px}.h-\[30vh\]{height:30vh}.h-\[31\.5px\]{height:31.5px}.h-\[32px\]{height:32px}.h-\[33px\]{height:33px}.h-\[34\.56px\]{height:34.56px}.h-\[34px\]{height:34px}.h-\[36px\]{height:36px}.h-\[38px\]{height:38px}.h-\[38px\]\!{height:38px!important}.h-\[40px\]{height:40px}.h-\[42px\]{height:42px}.h-\[44px\]{height:44px}.h-\[45px\]{height:45px}.h-\[48px\]{height:48px}.h-\[50\%\]{height:50%}.h-\[50dvh\]{height:50dvh}.h-\[50px\]{height:50px}.h-\[50vh\]{height:50vh}.h-\[54px\]{height:54px}.h-\[55px\]{height:55px}.h-\[56px\]{height:56px}.h-\[58px\]{height:58px}.h-\[60px\]{height:60px}.h-\[60vh\]{height:60vh}.h-\[62px\]{height:62px}.h-\[63px\]{height:63px}.h-\[64px\]{height:64px}.h-\[65px\]{height:65px}.h-\[66px\]{height:66px}.h-\[68px\]{height:68px}.h-\[68vh\]{height:68vh}.h-\[70\%\]{height:70%}.h-\[70px\]{height:70px}.h-\[70vh\]{height:70vh}.h-\[72px\]{height:72px}.h-\[76px\]{height:76px}.h-\[78px\]{height:78px}.h-\[80px\]{height:80px}.h-\[80vh\]{height:80vh}.h-\[80vh\]\!{height:80vh!important}.h-\[85vh\]{height:85vh}.h-\[86px\]{height:86px}.h-\[90px\]{height:90px}.h-\[90vh\]{height:90vh}.h-\[91px\]{height:91px}.h-\[92vh\]{height:92vh}.h-\[95dvh\]{height:95dvh}.h-\[96px\]{height:96px}.h-\[100\%\]{height:100%}.h-\[100cqh\]{height:100cqh}.h-\[100dvh\]{height:100dvh}.h-\[100px\]{height:100px}.h-\[100vh\]{height:100vh}.h-\[104\.65\%\]{height:104.65%}.h-\[104px\]{height:104px}.h-\[106\.19\%\]{height:106.19%}.h-\[116px\]{height:116px}.h-\[120px\]{height:120px}.h-\[122px\]{height:122px}.h-\[128px\]{height:128px}.h-\[132px\]{height:132px}.h-\[140px\]{height:140px}.h-\[144px\]{height:144px}.h-\[150px\]{height:150px}.h-\[157px\]{height:157px}.h-\[160\%\]{height:160%}.h-\[160px\]{height:160px}.h-\[168px\]{height:168px}.h-\[180px\]{height:180px}.h-\[184px\]{height:184px}.h-\[200\%\]{height:200%}.h-\[200px\]{height:200px}.h-\[213px\]{height:213px}.h-\[214px\]{height:214px}.h-\[220px\]{height:220px}.h-\[224px\]{height:224px}.h-\[225px\]{height:225px}.h-\[240px\]{height:240px}.h-\[244px\]{height:244px}.h-\[248px\]{height:248px}.h-\[249px\]{height:249px}.h-\[250px\]{height:250px}.h-\[280px\]{height:280px}.h-\[300px\]{height:300px}.h-\[320px\]{height:320px}.h-\[340px\]{height:340px}.h-\[350px\]{height:350px}.h-\[352px\]{height:352px}.h-\[375px\]{height:375px}.h-\[400\%\]{height:400%}.h-\[400px\]{height:400px}.h-\[420px\]{height:420px}.h-\[429px\]{height:429px}.h-\[440px\]{height:440px}.h-\[480px\]{height:480px}.h-\[500px\]{height:500px}.h-\[540px\]{height:540px}.h-\[550px\]{height:550px}.h-\[550px\]\!{height:550px!important}.h-\[572px\]{height:572px}.h-\[600px\]{height:600px}.h-\[640px\]{height:640px}.h-\[650px\]{height:650px}.h-\[700px\]{height:700px}.h-\[720px\]{height:720px}.h-\[860px\]{height:860px}.h-\[calc\(50vh-28px\)\]{height:calc(50vh - 28px)}.h-\[calc\(100\%\+2px\)\]{height:calc(100% + 2px)}.h-\[calc\(100\%\+var\(--snc-1\)\)\]{height:calc(100% + var(--snc-1))}.h-\[calc\(100\%-1rem\)\]{height:calc(100% - 1rem)}.h-\[calc\(100\%-48px\)\]{height:calc(100% - 48px)}.h-\[calc\(100\%-64px\)\]{height:calc(100% - 64px)}.h-\[calc\(100svh-max\(env\(safe-area-inset-bottom\)\,0px\)\)\]{height:calc(100svh - max(env(safe-area-inset-bottom),0px))}.h-\[calc\(100svh-max\(env\(safe-area-inset-bottom\)\,6px\)\)\]{height:calc(100svh - max(env(safe-area-inset-bottom),6px))}.h-\[calc\(100vh-2rem\)\]{height:calc(100vh - 2rem)}.h-\[calc\(100vh-25rem\)\]{height:calc(100vh - 25rem)}.h-\[calc\(100vh-72px\)\]\!{height:calc(100vh - 72px)!important}.h-\[calc\(100vh-325px\)\]{height:calc(100vh - 325px)}.h-\[calc\(clamp\(150px\,1\/4\*var\(--thread-safe-area-height\,100lvh\)\,400px\)\)\]{height:calc(clamp(150px,1/4*var(--thread-safe-area-height,100lvh),400px))}.h-\[calc\(var\(--cqh-full\)-32px\)\]{height:calc(var(--cqh-full) - 32px)}.h-\[calc\(var\(--header-height\)\+var\(--primary-items-height\,0\)\+4px\)\]{height:calc(var(--header-height) + var(--primary-items-height,0) + 4px)}.h-\[calc\(var\(--header-height\,3\.5rem\)\+1px\)\]{height:calc(var(--header-height,3.5rem) + 1px)}.h-\[inherit\]{height:inherit}.h-\[max\(3rem\,18vh\)\]{height:max(3rem,18vh)}.h-\[max\(20cqw\,100px\)\]{height:max(20cqw,100px)}.h-\[max\(100dvh\,100\%\)\]{height:max(100dvh,100%)}.h-\[max\(100svh\,100dvh\,100\%\)\]{height:max(100svh,100dvh,100%)}.h-\[max-content\]{height:-webkit-max-content;height:max-content}.h-\[var\(--cqh-full\)\]{height:var(--cqh-full)}.h-\[var\(--header-height\,3\.5rem\)\]{height:var(--header-height,3.5rem)}.h-\[var\(--screen-height-override\,calc\(var\(--cqh-full\)-var\(--screen-height-offset\,0px\)\)\)\]{height:var(--screen-height-override,calc(var(--cqh-full) - var(--screen-height-offset,0px)))}.h-auto{height:auto}.h-auto\!{height:auto!important}.h-dvh{height:100dvh}.h-fit{height:-webkit-fit-content;height:fit-content}.h-fit\!{height:-webkit-fit-content!important;height:fit-content!important}.h-full{height:100%}.h-full\!{height:100%!important}.h-header-height{height:var(--header-height)}.h-max{height:-webkit-max-content;height:max-content}.h-min{height:-webkit-min-content;height:min-content}.h-mkt-header-height{height:var(--mkt-header-height)}.h-px{height:1px}.h-screen{height:100vh}.h-snc-input-height{height:var(--snc-input-height)}.h-svh{height:100svh}.max-h-0{max-height:calc(var(--spacing)*0)}.max-h-2\/3{max-height:66.6667%}.max-h-6{max-height:calc(var(--spacing)*6)}.max-h-9{max-height:calc(var(--spacing)*9)}.max-h-16{max-height:calc(var(--spacing)*16)}.max-h-24{max-height:calc(var(--spacing)*24)}.max-h-28{max-height:calc(var(--spacing)*28)}.max-h-32{max-height:calc(var(--spacing)*32)}.max-h-36{max-height:calc(var(--spacing)*36)}.max-h-40{max-height:calc(var(--spacing)*40)}.max-h-48{max-height:calc(var(--spacing)*48)}.max-h-52{max-height:calc(var(--spacing)*52)}.max-h-56{max-height:calc(var(--spacing)*56)}.max-h-60{max-height:calc(var(--spacing)*60)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-72{max-height:calc(var(--spacing)*72)}.max-h-80{max-height:calc(var(--spacing)*80)}.max-h-84{max-height:calc(var(--spacing)*84)}.max-h-94{max-height:calc(var(--spacing)*94)}.max-h-96{max-height:calc(var(--spacing)*96)}.max-h-\[2lh\]{max-height:2lh}.max-h-\[14rem\]{max-height:14rem}.max-h-\[16rem\]{max-height:16rem}.max-h-\[18rem\]{max-height:18rem}.max-h-\[20px\]{max-height:20px}.max-h-\[24rem\]{max-height:24rem}.max-h-\[25dvh\]{max-height:25dvh}.max-h-\[28rem\]{max-height:28rem}.max-h-\[28vh\]{max-height:28vh}.max-h-\[34rem\]{max-height:34rem}.max-h-\[36rem\]{max-height:36rem}.max-h-\[40vh\]{max-height:40vh}.max-h-\[48vh\]{max-height:48vh}.max-h-\[50\%\]{max-height:50%}.max-h-\[50dvh\]{max-height:50dvh}.max-h-\[50vh\]{max-height:50vh}.max-h-\[60dvh\]{max-height:60dvh}.max-h-\[60vh\]{max-height:60vh}.max-h-\[64px\]{max-height:64px}.max-h-\[65vh\]{max-height:65vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[72vh\]{max-height:72vh}.max-h-\[75vh\]{max-height:75vh}.max-h-\[80vh\]{max-height:80vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[95\%\]{max-height:95%}.max-h-\[95vh\]{max-height:95vh}.max-h-\[100px\]{max-height:100px}.max-h-\[100vh\]\!{max-height:100vh!important}.max-h-\[147px\]{max-height:147px}.max-h-\[180px\]{max-height:180px}.max-h-\[192px\]{max-height:192px}.max-h-\[200px\]{max-height:200px}.max-h-\[220px\]{max-height:220px}.max-h-\[240px\]{max-height:240px}.max-h-\[245px\]{max-height:245px}.max-h-\[250px\]{max-height:250px}.max-h-\[260px\]{max-height:260px}.max-h-\[272px\]{max-height:272px}.max-h-\[280px\]{max-height:280px}.max-h-\[294px\]{max-height:294px}.max-h-\[295px\]{max-height:295px}.max-h-\[300px\]{max-height:300px}.max-h-\[320px\]{max-height:320px}.max-h-\[350px\]{max-height:350px}.max-h-\[360px\]{max-height:360px}.max-h-\[400px\]{max-height:400px}.max-h-\[420px\]{max-height:420px}.max-h-\[440px\]{max-height:440px}.max-h-\[500px\]{max-height:500px}.max-h-\[520px\]{max-height:520px}.max-h-\[550px\]{max-height:550px}.max-h-\[572px\]{max-height:572px}.max-h-\[600px\]{max-height:600px}.max-h-\[700px\]{max-height:700px}.max-h-\[724px\]{max-height:724px}.max-h-\[820px\]{max-height:820px}.max-h-\[900px\]{max-height:900px}.max-h-\[920px\]{max-height:920px}.max-h-\[1000px\]{max-height:1000px}.max-h-\[calc\(100\%-100px\)\]{max-height:calc(100% - 100px)}.max-h-\[calc\(100\%-max\(env\(safe-area-inset-top\)\,6px\)\)\]{max-height:calc(100% - max(env(safe-area-inset-top),6px))}.max-h-\[calc\(100\%-max\(env\(safe-area-inset-top\)\,36px\)\)\]{max-height:calc(100% - max(env(safe-area-inset-top),36px))}.max-h-\[calc\(100\%-max\(env\(safe-area-inset-top\)\,56px\)\)\]{max-height:calc(100% - max(env(safe-area-inset-top),56px))}.max-h-\[calc\(100vh-0px\)\]{max-height:100vh}.max-h-\[calc\(100vh-2rem\)\]{max-height:calc(100vh - 2rem)}.max-h-\[calc\(100vh-46px\)\]{max-height:calc(100vh - 46px)}.max-h-\[calc\(100vh-80px\)\]\!{max-height:calc(100vh - 80px)!important}.max-h-\[calc\(100vh-150px\)\]{max-height:calc(100vh - 150px)}.max-h-\[calc\(100vh-176px\)\]{max-height:calc(100vh - 176px)}.max-h-\[calc\(100vh-300px\)\]{max-height:calc(100vh - 300px)}.max-h-\[calc\(clamp\(20px\,1\/4\*var\(--thread-safe-area-height\,100lvh\)\,400px\)\)\]{max-height:calc(clamp(20px,1/4*var(--thread-safe-area-height,100lvh),400px))}.max-h-\[calc\(clamp\(20px\,1\/8\*var\(--thread-safe-area-height\,100lvh\)\,200px\)\)\]{max-height:calc(clamp(20px,1/8*var(--thread-safe-area-height,100lvh),200px))}.max-h-\[calc\(var\(--cqh-full\)-32px\)\]{max-height:calc(var(--cqh-full) - 32px)}.max-h-\[calc\(var\(--radix-popper-available-height\)-2rem\)\]{max-height:calc(var(--radix-popper-available-height) - 2rem)}.max-h-\[max\(10rem\,min\(calc\(100dvh-29rem\)\,10rem\)\)\]{max-height:max(10rem,min(100dvh - 29rem,10rem))}.max-h-\[max\(10rem\,min\(calc\(100dvh-29rem\)\,50dvh\)\)\]{max-height:max(10rem,min(100dvh - 29rem,50dvh))}.max-h-\[max\(30svh\,5rem\)\]{max-height:max(30svh,5rem)}.max-h-\[min\(40vh\,360px\)\]{max-height:min(40vh,360px)}.max-h-\[min\(40vh\,492px\)\]{max-height:min(40vh,492px)}.max-h-\[min\(72vh\,760px\)\]{max-height:min(72vh,760px)}.max-h-\[min\(80vh\,720px\)\]{max-height:min(80vh,720px)}.max-h-\[min\(90vh\,900px\)\]{max-height:min(90vh,900px)}.max-h-\[min\(360px\,50vh\)\]{max-height:min(360px,50vh)}.max-h-\[min\(var\(--radix-dropdown-menu-content-available-height\,50svh\)\,--spacing\(1\.5\)\+var\(--min-items\,6\.8\)\*var\(--menu-item-height\)\)\]\!{max-height:min(var(--radix-dropdown-menu-content-available-height,50svh),calc(var(--spacing)*1.5) + var(--min-items,6.8)*var(--menu-item-height))!important}.max-h-\[min\(var\(--radix-popper-available-height\,50svh\)\,--spacing\(1\.5\)\+5\*var\(--menu-item-height\)\)\]{max-height:min(var(--radix-popper-available-height,50svh),calc(var(--spacing)*1.5) + 5*var(--menu-item-height))}.max-h-\[var\(--cqh-full\)\]{max-height:var(--cqh-full)}.max-h-\[var\(--radix-dropdown-menu-content-available-height\)\]{max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\[var\(--radix-popper-available-height\,50svh\)\]{max-height:var(--radix-popper-available-height,50svh)}.max-h-\[var\(--radix-select-content-available-height\)\]{max-height:var(--radix-select-content-available-height)}.max-h-dvh{max-height:100dvh}.max-h-fit{max-height:-webkit-fit-content;max-height:fit-content}.max-h-full{max-height:100%}.max-h-none{max-height:none}.max-h-none\!{max-height:none!important}.max-h-svh{max-height:100svh}.btn-small{min-height:calc(var(--spacing)*7);padding-inline:calc(var(--spacing)*2.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.btn-large{min-height:calc(var(--spacing)*11);padding-inline:calc(var(--spacing)*4)}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-0\!{min-height:calc(var(--spacing)*0)!important}.min-h-4{min-height:calc(var(--spacing)*4)}.min-h-5{min-height:calc(var(--spacing)*5)}.min-h-6{min-height:calc(var(--spacing)*6)}.min-h-7{min-height:calc(var(--spacing)*7)}.min-h-8{min-height:calc(var(--spacing)*8)}.min-h-9{min-height:calc(var(--spacing)*9)}.min-h-10{min-height:calc(var(--spacing)*10)}.min-h-10\.5{min-height:calc(var(--spacing)*10.5)}.min-h-11{min-height:calc(var(--spacing)*11)}.min-h-12{min-height:calc(var(--spacing)*12)}.min-h-12\!{min-height:calc(var(--spacing)*12)!important}.min-h-14{min-height:calc(var(--spacing)*14)}.min-h-15{min-height:calc(var(--spacing)*15)}.min-h-16{min-height:calc(var(--spacing)*16)}.min-h-18{min-height:calc(var(--spacing)*18)}.min-h-20{min-height:calc(var(--spacing)*20)}.min-h-21{min-height:calc(var(--spacing)*21)}.min-h-24{min-height:calc(var(--spacing)*24)}.min-h-28{min-height:calc(var(--spacing)*28)}.min-h-30{min-height:calc(var(--spacing)*30)}.min-h-32{min-height:calc(var(--spacing)*32)}.min-h-36{min-height:calc(var(--spacing)*36)}.min-h-40{min-height:calc(var(--spacing)*40)}.min-h-44{min-height:calc(var(--spacing)*44)}.min-h-48{min-height:calc(var(--spacing)*48)}.min-h-56{min-height:calc(var(--spacing)*56)}.min-h-60{min-height:calc(var(--spacing)*60)}.min-h-64{min-height:calc(var(--spacing)*64)}.min-h-72{min-height:calc(var(--spacing)*72)}.min-h-75{min-height:calc(var(--spacing)*75)}.min-h-80{min-height:calc(var(--spacing)*80)}.min-h-96{min-height:calc(var(--spacing)*96)}.min-h-\[1\.5rem\]{min-height:1.5rem}.min-h-\[1\.25rem\]{min-height:1.25rem}.min-h-\[1\.625rem\]{min-height:1.625rem}.min-h-\[2\.5rem\]{min-height:2.5rem}.min-h-\[2\.75rem\]{min-height:2.75rem}.min-h-\[3\.5rem\]{min-height:3.5rem}.min-h-\[3\.25rem\]{min-height:3.25rem}.min-h-\[4\.5rem\]{min-height:4.5rem}.min-h-\[5rem\]{min-height:5rem}.min-h-\[6\.25rem\]{min-height:6.25rem}.min-h-\[6rem\]{min-height:6rem}.min-h-\[8rem\]{min-height:8rem}.min-h-\[12rem\]{min-height:12rem}.min-h-\[17rem\]{min-height:17rem}.min-h-\[18\.75rem\]{min-height:18.75rem}.min-h-\[18rem\]{min-height:18rem}.min-h-\[20px\]{min-height:20px}.min-h-\[20rem\]{min-height:20rem}.min-h-\[24rem\]{min-height:24rem}.min-h-\[30px\]{min-height:30px}.min-h-\[32px\]{min-height:32px}.min-h-\[36px\]{min-height:36px}.min-h-\[36px\]\!{min-height:36px!important}.min-h-\[38px\]{min-height:38px}.min-h-\[38rem\]{min-height:38rem}.min-h-\[40\%\]{min-height:40%}.min-h-\[40px\]{min-height:40px}.min-h-\[40px\]\!{min-height:40px!important}.min-h-\[40vh\]{min-height:40vh}.min-h-\[41px\]{min-height:41px}.min-h-\[44px\]{min-height:44px}.min-h-\[46px\]{min-height:46px}.min-h-\[48px\]{min-height:48px}.min-h-\[50dvh\]{min-height:50dvh}.min-h-\[50px\]{min-height:50px}.min-h-\[50vh\]{min-height:50vh}.min-h-\[52px\]{min-height:52px}.min-h-\[56px\]{min-height:56px}.min-h-\[57px\]{min-height:57px}.min-h-\[60px\]{min-height:60px}.min-h-\[60vh\]{min-height:60vh}.min-h-\[62px\]{min-height:62px}.min-h-\[64px\]{min-height:64px}.min-h-\[70vh\]{min-height:70vh}.min-h-\[72px\]{min-height:72px}.min-h-\[74px\]{min-height:74px}.min-h-\[75vh\]{min-height:75vh}.min-h-\[76px\]{min-height:76px}.min-h-\[80px\]{min-height:80px}.min-h-\[80vh\]{min-height:80vh}.min-h-\[84px\]{min-height:84px}.min-h-\[88px\]{min-height:88px}.min-h-\[90px\]{min-height:90px}.min-h-\[90vh\]{min-height:90vh}.min-h-\[96px\]{min-height:96px}.min-h-\[100dvh\]{min-height:100dvh}.min-h-\[100px\]{min-height:100px}.min-h-\[104px\]{min-height:104px}.min-h-\[108px\]{min-height:108px}.min-h-\[112px\]{min-height:112px}.min-h-\[120px\]{min-height:120px}.min-h-\[120px\]\!{min-height:120px!important}.min-h-\[140px\]{min-height:140px}.min-h-\[144px\]{min-height:144px}.min-h-\[160px\]{min-height:160px}.min-h-\[168px\]{min-height:168px}.min-h-\[170px\]{min-height:170px}.min-h-\[180px\]{min-height:180px}.min-h-\[192px\]{min-height:192px}.min-h-\[196px\]{min-height:196px}.min-h-\[200px\]{min-height:200px}.min-h-\[200vh\]{min-height:200vh}.min-h-\[208px\]{min-height:208px}.min-h-\[220px\]{min-height:220px}.min-h-\[221px\]{min-height:221px}.min-h-\[230px\]{min-height:230px}.min-h-\[240px\]{min-height:240px}.min-h-\[245px\]{min-height:245px}.min-h-\[248px\]{min-height:248px}.min-h-\[250px\]{min-height:250px}.min-h-\[260px\]{min-height:260px}.min-h-\[280px\]{min-height:280px}.min-h-\[320px\]{min-height:320px}.min-h-\[321px\]{min-height:321px}.min-h-\[350px\]{min-height:350px}.min-h-\[360px\]{min-height:360px}.min-h-\[364px\]{min-height:364px}.min-h-\[380px\]{min-height:380px}.min-h-\[400px\]{min-height:400px}.min-h-\[420px\]{min-height:420px}.min-h-\[429px\]{min-height:429px}.min-h-\[440px\]{min-height:440px}.min-h-\[460px\]{min-height:460px}.min-h-\[480px\]{min-height:480px}.min-h-\[500px\]\!{min-height:500px!important}.min-h-\[520px\]{min-height:520px}.min-h-\[540px\]{min-height:540px}.min-h-\[560px\]{min-height:560px}.min-h-\[600px\]{min-height:600px}.min-h-\[640px\]{min-height:640px}.min-h-\[664px\]{min-height:664px}.min-h-\[680px\]{min-height:680px}.min-h-\[700px\]{min-height:700px}.min-h-\[755px\]{min-height:755px}.min-h-\[820px\]{min-height:820px}.min-h-\[900px\]{min-height:900px}.min-h-\[calc\(100dvh-var\(--header-height\)-88px\)\]{min-height:calc(100dvh - var(--header-height) - 88px)}.min-h-\[calc\(100vh-var\(--header-height\)-2rem\)\]{min-height:calc(100vh - var(--header-height) - 2rem)}.min-h-\[calc\(298px\*5\/4\)\]{min-height:372.5px}.min-h-\[calc\(var\(--header-height\,3\.5rem\)\+1px\)\]{min-height:calc(var(--header-height,3.5rem) + 1px)}.min-h-\[max\(var\(--gutter-min-height\,0px\)\,var\(--gutter-remaining-height\,0px\)\)\]{min-height:max(var(--gutter-min-height,0px),var(--gutter-remaining-height,0px))}.min-h-\[min\(1000px\,max\(0px\,calc\(100vh-var\(--mkt-header-height\)\)\)\)\]{min-height:min(1000px,max(0px,calc(100vh - var(--mkt-header-height))))}.min-h-\[min\(calc\(80vh-4rem\)\,32rem\)\]{min-height:min(80vh - 4rem,32rem)}.min-h-\[unset\]{min-height:unset}.min-h-\[var\(--deep-research-composer-extra-height\,unset\)\]{min-height:var(--deep-research-composer-extra-height,unset)}.min-h-\[var\(--header-height\,3\.5rem\)\]{min-height:var(--header-height,3.5rem)}.min-h-bloop{min-height:227px}.min-h-dvh{min-height:100dvh}.min-h-fit{min-height:-webkit-fit-content;min-height:fit-content}.min-h-fit\!{min-height:-webkit-fit-content!important;min-height:fit-content!important}.min-h-full{min-height:100%}.min-h-header-height{min-height:var(--header-height)}.min-h-px{min-height:1px}.min-h-screen{min-height:100vh}.min-h-svh{min-height:100svh}.\!w-7{width:calc(var(--spacing)*7)!important}.\!w-8{width:calc(var(--spacing)*8)!important}.\!w-full{width:100%!important}.\[width\:min\(90cqw\,var\(--thread-content-max-width\)\)\]{width:min(90cqw,var(--thread-content-max-width))}.\[width\:var\(--stage-thread-flyout-override-width\,var\(--stage-thread-flyout-preset-width\,400px\)\)\]{width:var(--stage-thread-flyout-override-width,var(--stage-thread-flyout-preset-width,400px))}.w-\(--file-tile-action-size\){width:var(--file-tile-action-size)}.w-\(--file-tile-width\){width:var(--file-tile-width)}.w-\(--sidebar-rail-width\){width:var(--sidebar-rail-width)}.w-\(--sidebar-width\){width:var(--sidebar-width)}.w-0{width:calc(var(--spacing)*0)}.w-0\!{width:calc(var(--spacing)*0)!important}.w-0\.5{width:calc(var(--spacing)*.5)}.w-1{width:calc(var(--spacing)*1)}.w-1\.5{width:calc(var(--spacing)*1.5)}.w-1\.75{width:calc(var(--spacing)*1.75)}.w-1\/2{width:50%}.w-1\/3{width:33.3333%}.w-1\/4{width:25%}.w-2{width:calc(var(--spacing)*2)}.w-2\.5{width:calc(var(--spacing)*2.5)}.w-2\.75{width:calc(var(--spacing)*2.75)}.w-2\/3{width:66.6667%}.w-2\/5{width:40%}.w-2xl{width:var(--container-2xl)}.w-3{width:calc(var(--spacing)*3)}.w-3\.5{width:calc(var(--spacing)*3.5)}.w-3\/4{width:75%}.w-3\/5{width:60%}.w-4{width:calc(var(--spacing)*4)}.w-4\.5{width:calc(var(--spacing)*4.5)}.w-4\/5{width:80%}.w-4\/5\!{width:80%!important}.w-5{width:calc(var(--spacing)*5)}.w-5\/6{width:83.3333%}.w-6{width:calc(var(--spacing)*6)}.w-6\!{width:calc(var(--spacing)*6)!important}.w-7{width:calc(var(--spacing)*7)}.w-7\!{width:calc(var(--spacing)*7)!important}.w-7\/8{width:87.5%}.w-8{width:calc(var(--spacing)*8)}.w-8\!{width:calc(var(--spacing)*8)!important}.w-9{width:calc(var(--spacing)*9)}.w-9\!{width:calc(var(--spacing)*9)!important}.w-9\.5{width:calc(var(--spacing)*9.5)}.w-9\/12{width:75%}.w-10{width:calc(var(--spacing)*10)}.w-10\/12{width:83.3333%}.w-11{width:calc(var(--spacing)*11)}.w-11\/12{width:91.6667%}.w-12{width:calc(var(--spacing)*12)}.w-12\.5{width:calc(var(--spacing)*12.5)}.w-14{width:calc(var(--spacing)*14)}.w-15{width:calc(var(--spacing)*15)}.w-16{width:calc(var(--spacing)*16)}.w-18{width:calc(var(--spacing)*18)}.w-20{width:calc(var(--spacing)*20)}.w-20\!{width:calc(var(--spacing)*20)!important}.w-22{width:calc(var(--spacing)*22)}.w-24{width:calc(var(--spacing)*24)}.w-25{width:calc(var(--spacing)*25)}.w-28{width:calc(var(--spacing)*28)}.w-30{width:calc(var(--spacing)*30)}.w-32{width:calc(var(--spacing)*32)}.w-36{width:calc(var(--spacing)*36)}.w-40{width:calc(var(--spacing)*40)}.w-44{width:calc(var(--spacing)*44)}.w-46{width:calc(var(--spacing)*46)}.w-48{width:calc(var(--spacing)*48)}.w-50{width:calc(var(--spacing)*50)}.w-52{width:calc(var(--spacing)*52)}.w-54{width:calc(var(--spacing)*54)}.w-56{width:calc(var(--spacing)*56)}.w-60{width:calc(var(--spacing)*60)}.w-64{width:calc(var(--spacing)*64)}.w-66{width:calc(var(--spacing)*66)}.w-68{width:calc(var(--spacing)*68)}.w-70{width:calc(var(--spacing)*70)}.w-72{width:calc(var(--spacing)*72)}.w-74{width:calc(var(--spacing)*74)}.w-80{width:calc(var(--spacing)*80)}.w-84{width:calc(var(--spacing)*84)}.w-90{width:calc(var(--spacing)*90)}.w-96{width:calc(var(--spacing)*96)}.w-120{width:calc(var(--spacing)*120)}.w-\[0\.75em\]{width:.75em}.w-\[0\.75rem\]{width:.75rem}.w-\[1lh\]\!{width:1lh!important}.w-\[1px\]{width:1px}.w-\[2px\]{width:2px}.w-\[3px\]{width:3px}.w-\[4px\]{width:4px}.w-\[4rem\]{width:4rem}.w-\[6\.5px\]{width:6.5px}.w-\[6px\]{width:6px}.w-\[7\.5rem\]{width:7.5rem}.w-\[8px\]{width:8px}.w-\[8rem\]{width:8rem}.w-\[10\%\]{width:10%}.w-\[11px\]{width:11px}.w-\[11rem\]{width:11rem}.w-\[12px\]{width:12px}.w-\[13\.333px\]{width:13.333px}.w-\[14px\]{width:14px}.w-\[15\.83px\]{width:15.83px}.w-\[15px\]{width:15px}.w-\[16px\]{width:16px}.w-\[17px\]{width:17px}.w-\[18px\]{width:18px}.w-\[18px\]\!{width:18px!important}.w-\[19ch\]{width:19ch}.w-\[19px\]{width:19px}.w-\[20\%\]{width:20%}.w-\[20px\]{width:20px}.w-\[21px\]{width:21px}.w-\[22\%\]{width:22%}.w-\[22px\]{width:22px}.w-\[22rem\]{width:22rem}.w-\[23px\]{width:23px}.w-\[24px\]{width:24px}.w-\[24rem\]{width:24rem}.w-\[25\%\]{width:25%}.w-\[25vw\]{width:25vw}.w-\[27px\]{width:27px}.w-\[28px\]{width:28px}.w-\[28rem\]{width:28rem}.w-\[30\%\]{width:30%}.w-\[30px\]{width:30px}.w-\[31\.5px\]{width:31.5px}.w-\[33px\]{width:33px}.w-\[34\%\]{width:34%}.w-\[34\.56px\]{width:34.56px}.w-\[34px\]{width:34px}.w-\[35\%\]{width:35%}.w-\[36\%\]{width:36%}.w-\[36px\]{width:36px}.w-\[37\%\]{width:37%}.w-\[37px\]{width:37px}.w-\[38\%\]{width:38%}.w-\[40\%\]{width:40%}.w-\[40px\]{width:40px}.w-\[41\%\]{width:41%}.w-\[42\%\]{width:42%}.w-\[42px\]{width:42px}.w-\[43\%\]{width:43%}.w-\[44\%\]{width:44%}.w-\[45\%\]{width:45%}.w-\[45px\]{width:45px}.w-\[48px\]{width:48px}.w-\[50\%\]{width:50%}.w-\[50vw\]{width:50vw}.w-\[52px\]{width:52px}.w-\[54px\]{width:54px}.w-\[56px\]{width:56px}.w-\[58\%\]{width:58%}.w-\[58px\]{width:58px}.w-\[59px\]{width:59px}.w-\[60\%\]{width:60%}.w-\[60px\]{width:60px}.w-\[62\%\]{width:62%}.w-\[62px\]{width:62px}.w-\[64\%\]{width:64%}.w-\[64px\]{width:64px}.w-\[65px\]{width:65px}.w-\[66\%\]{width:66%}.w-\[70\%\]{width:70%}.w-\[70px\]{width:70px}.w-\[72\%\]{width:72%}.w-\[72px\]{width:72px}.w-\[74\%\]{width:74%}.w-\[75\%\]{width:75%}.w-\[75px\]{width:75px}.w-\[76\%\]{width:76%}.w-\[77\%\]{width:77%}.w-\[78\%\]{width:78%}.w-\[78px\]{width:78px}.w-\[80\%\]{width:80%}.w-\[80px\]{width:80px}.w-\[80vw\]\!{width:80vw!important}.w-\[81\%\]{width:81%}.w-\[82\%\]{width:82%}.w-\[82vw\]{width:82vw}.w-\[83\%\]{width:83%}.w-\[84\%\]{width:84%}.w-\[84px\]{width:84px}.w-\[85\%\]{width:85%}.w-\[86\%\]{width:86%}.w-\[88\%\]{width:88%}.w-\[88px\]{width:88px}.w-\[90\%\]{width:90%}.w-\[90px\]{width:90px}.w-\[90vw\]{width:90vw}.w-\[92\%\]{width:92%}.w-\[92px\]{width:92px}.w-\[92vw\]{width:92vw}.w-\[95vw\]{width:95vw}.w-\[96\%\]{width:96%}.w-\[96px\]{width:96px}.w-\[100\%\]{width:100%}.w-\[100cqw\]{width:100cqw}.w-\[100dvw\]{width:100dvw}.w-\[100px\]{width:100px}.w-\[104px\]{width:104px}.w-\[105px\]{width:105px}.w-\[113px\]{width:113px}.w-\[115px\]{width:115px}.w-\[116px\]{width:116px}.w-\[120px\]{width:120px}.w-\[125px\]{width:125px}.w-\[128px\]{width:128px}.w-\[140px\]{width:140px}.w-\[142px\]{width:142px}.w-\[160px\]{width:160px}.w-\[164px\]{width:164px}.w-\[170px\]{width:170px}.w-\[180px\]{width:180px}.w-\[190\%\]{width:190%}.w-\[200\%\]{width:200%}.w-\[200\.04\%\]{width:200.04%}.w-\[200px\]{width:200px}.w-\[201px\]{width:201px}.w-\[210px\]{width:210px}.w-\[220px\]{width:220px}.w-\[221px\]{width:221px}.w-\[222px\]{width:222px}.w-\[228px\]{width:228px}.w-\[230px\]{width:230px}.w-\[232px\]{width:232px}.w-\[240px\]{width:240px}.w-\[248px\]{width:248px}.w-\[250px\]{width:250px}.w-\[256px\]{width:256px}.w-\[258px\]{width:258px}.w-\[260px\]{width:260px}.w-\[268px\]{width:268px}.w-\[280px\]{width:280px}.w-\[286px\]{width:286px}.w-\[290px\]{width:290px}.w-\[294px\]{width:294px}.w-\[298px\]{width:298px}.w-\[300px\]{width:300px}.w-\[304px\]{width:304px}.w-\[320px\]{width:320px}.w-\[324px\]{width:324px}.w-\[340px\]{width:340px}.w-\[345px\]{width:345px}.w-\[350px\]{width:350px}.w-\[360px\]{width:360px}.w-\[380px\]{width:380px}.w-\[393px\]{width:393px}.w-\[400\%\]{width:400%}.w-\[400px\]{width:400px}.w-\[400px\]\!{width:400px!important}.w-\[416px\]{width:416px}.w-\[420px\]\!{width:420px!important}.w-\[430px\]{width:430px}.w-\[432px\]{width:432px}.w-\[440px\]{width:440px}.w-\[448px\]{width:448px}.w-\[450px\]{width:450px}.w-\[480px\]{width:480px}.w-\[500px\]{width:500px}.w-\[520px\]{width:520px}.w-\[540px\]{width:540px}.w-\[550px\]{width:550px}.w-\[600px\]{width:600px}.w-\[640px\]{width:640px}.w-\[680px\]\!{width:680px!important}.w-\[700px\]{width:700px}.w-\[720px\]{width:720px}.w-\[780px\]{width:780px}.w-\[800px\]{width:800px}.w-\[calc\(50\%-6px\)\]{width:calc(50% - 6px)}.w-\[calc\(100\%\+--spacing\(2\.5\)\)\]{width:calc(100% + calc(var(--spacing)*2.5))}.w-\[calc\(100\%\+2px\)\]{width:calc(100% + 2px)}.w-\[calc\(100\%\+2rem\)\]{width:calc(100% + 2rem)}.w-\[calc\(100\%\+32px\)\]{width:calc(100% + 32px)}.w-\[calc\(100\%\+40px\)\]{width:calc(100% + 40px)}.w-\[calc\(100\%---spacing\(3\)\)\]{width:calc(100% - calc(var(--spacing)*3))}.w-\[calc\(100\%-1\.5rem\)\]{width:calc(100% - 1.5rem)}.w-\[calc\(100\%-2\*1rem\)\],.w-\[calc\(100\%-2rem\)\]{width:calc(100% - 2rem)}.w-\[calc\(100\%-16px\)\]\!{width:calc(100% - 16px)!important}.w-\[calc\(100\%-32px\)\]\!{width:calc(100% - 32px)!important}.w-\[calc\(100\%-var\(--sidebar-width\)\)\]{width:calc(100% - var(--sidebar-width))}.w-\[calc\(100\%_-_32px\)\]{width:calc(100% - 32px)}.w-\[calc\(100vw-2\.5rem\)\]{width:calc(100vw - 2.5rem)}.w-\[calc\(100vw-2rem\)\]{width:calc(100vw - 2rem)}.w-\[calc\(100vw-var\(--builder-sidebar-width\)-48px\)\]{width:calc(100vw - var(--builder-sidebar-width) - 48px)}.w-\[calc\(var\(--pricing-table-padding-inline\)\+var\(--pricing-table-label-min-width\)\)\]{width:calc(var(--pricing-table-padding-inline) + var(--pricing-table-label-min-width))}.w-\[fit-content\]{width:-webkit-fit-content;width:fit-content}.w-\[max\(20cqw\,100px\)\]{width:max(20cqw,100px)}.w-\[max\(95vw\,300px\)\]{width:max(95vw,300px)}.w-\[max-content\]{width:-webkit-max-content;width:max-content}.w-\[min\(45\%\,10rem\)\]{width:min(45%,10rem)}.w-\[min\(70\%\,16rem\)\]{width:min(70%,16rem)}.w-\[min\(80vw\,22em\)\]{width:min(80vw,22em)}.w-\[min\(90vw\,40rem\)\]{width:min(90vw,40rem)}.w-\[min\(90vw\,600px\)\]{width:min(90vw,600px)}.w-\[min\(92vw\,520px\)\]{width:min(92vw,520px)}.w-\[min\(92vw\,680px\)\]{width:min(92vw,680px)}.w-\[min\(94vw\,1100px\)\]{width:min(94vw,1100px)}.w-\[min\(100\%\,40rem\)\]{width:min(100%,40rem)}.w-\[min\(100cqw\,800px\)\]{width:min(100cqw,800px)}.w-\[min\(100vw\,980px\)\]{width:min(100vw,980px)}.w-\[min\(320px\,95vw\)\]{width:min(320px,95vw)}.w-\[min\(360px\,calc\(100vw-2rem\)\)\]{width:min(360px,100vw - 2rem)}.w-\[min\(400px\,100dvw\)\]{width:min(400px,100dvw)}.w-\[min\(420px\,65\%\)\]{width:min(420px,65%)}.w-\[min\(420px\,95dvw\)\]{width:min(420px,95dvw)}.w-\[min\(480px\,calc\(100vw-16px\)\)\]{width:min(480px,100vw - 16px)}.w-\[min\(560px\,85\%\)\]{width:min(560px,85%)}.w-\[min\(620px\,90\%\)\]{width:min(620px,90%)}.w-\[min\(680px\,40\%\)\]{width:min(680px,40%)}.w-\[min\(720px\,calc\(100vw-16px\)\)\]{width:min(720px,100vw - 16px)}.w-\[min\(820px\,68\%\)\]{width:min(820px,68%)}.w-\[min\(855px\,calc\(100vw-64px\)\)\]\!{width:min(855px,100vw - 64px)!important}.w-\[min\(1800px\,100cqw\)\]{width:min(1800px,100cqw)}.w-\[var\(--places-business-list-width\)\]{width:var(--places-business-list-width)}.w-\[var\(--pricing-table-padding-inline\)\]{width:var(--pricing-table-padding-inline)}.w-\[var\(--radix-dropdown-menu-trigger-width\)\]{width:var(--radix-dropdown-menu-trigger-width)}.w-\[var\(--radix-popper-anchor-width\)\]{width:var(--radix-popper-anchor-width)}.w-\[var\(--radix-select-trigger-width\)\]{width:var(--radix-select-trigger-width)}.w-\[var\(--sidebar-width\)\]{width:var(--sidebar-width)}.w-\[var\(--user-chat-width\,70\%\)\]{width:var(--user-chat-width,70%)}.w-auto{width:auto}.w-auto\!{width:auto!important}.w-dvw{width:100dvw}.w-fit{width:-webkit-fit-content;width:fit-content}.w-fit\!{width:-webkit-fit-content!important;width:fit-content!important}.w-full{width:100%}.w-full\!{width:100%!important}.w-lg{width:var(--container-lg)}.w-max{width:-webkit-max-content;width:max-content}.w-md{width:var(--container-md)}.w-min{width:-webkit-min-content;width:min-content}.w-px{width:1px}.w-screen{width:100vw}.w-sm{width:var(--container-sm)}.w-xl{width:var(--container-xl)}.\!max-w-\[180px\]{max-width:180px!important}.\!max-w-none{max-width:none!important}.max-w-\(--breakpoint-2xl\){max-width:var(--breakpoint-2xl)}.max-w-\(--breakpoint-md\){max-width:var(--breakpoint-md)}.max-w-\(--builder-content-width\){max-width:var(--builder-content-width)}.max-w-\(--sidebar-width\){max-width:var(--sidebar-width)}.max-w-\(--thread-content-max-width\){max-width:var(--thread-content-max-width)}.max-w-\(--user-chat-width\,70\%\){max-width:var(--user-chat-width,70%)}.max-w-0{max-width:calc(var(--spacing)*0)}.max-w-2xl{max-width:var(--container-2xl)}.max-w-2xs{max-width:240px}.max-w-2xs\!{max-width:240px!important}.max-w-3xl{max-width:var(--container-3xl)}.max-w-3xl\!{max-width:var(--container-3xl)!important}.max-w-3xs{max-width:256px}.max-w-4\/5{max-width:80%}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-8xl{max-width:1408px}.max-w-9{max-width:calc(var(--spacing)*9)}.max-w-9xl{max-width:1536px}.max-w-16{max-width:calc(var(--spacing)*16)}.max-w-20{max-width:calc(var(--spacing)*20)}.max-w-24{max-width:calc(var(--spacing)*24)}.max-w-28{max-width:calc(var(--spacing)*28)}.max-w-32{max-width:calc(var(--spacing)*32)}.max-w-40{max-width:calc(var(--spacing)*40)}.max-w-48{max-width:calc(var(--spacing)*48)}.max-w-50{max-width:calc(var(--spacing)*50)}.max-w-52{max-width:calc(var(--spacing)*52)}.max-w-56{max-width:calc(var(--spacing)*56)}.max-w-60{max-width:calc(var(--spacing)*60)}.max-w-62{max-width:calc(var(--spacing)*62)}.max-w-64{max-width:calc(var(--spacing)*64)}.max-w-66{max-width:calc(var(--spacing)*66)}.max-w-72{max-width:calc(var(--spacing)*72)}.max-w-80{max-width:calc(var(--spacing)*80)}.max-w-92{max-width:calc(var(--spacing)*92)}.max-w-96{max-width:calc(var(--spacing)*96)}.max-w-100{max-width:25rem}.max-w-112\.5{max-width:calc(var(--spacing)*112.5)}.max-w-124{max-width:calc(var(--spacing)*124)}.max-w-170{max-width:calc(var(--spacing)*170)}.max-w-194{max-width:calc(var(--spacing)*194)}.max-w-250{max-width:calc(var(--spacing)*250)}.max-w-\[7\.5rem\]{max-width:7.5rem}.max-w-\[7rem\]{max-width:7rem}.max-w-\[8rem\]{max-width:8rem}.max-w-\[10rem\]{max-width:10rem}.max-w-\[12ch\]{max-width:12ch}.max-w-\[12rem\]{max-width:12rem}.max-w-\[15ch\]{max-width:15ch}.max-w-\[16rem\]{max-width:16rem}.max-w-\[18rem\]{max-width:18rem}.max-w-\[20ch\]{max-width:20ch}.max-w-\[20rem\]{max-width:20rem}.max-w-\[22\%\]{max-width:22%}.max-w-\[22rem\]{max-width:22rem}.max-w-\[24rem\]{max-width:24rem}.max-w-\[28ch\]{max-width:28ch}.max-w-\[28rem\]{max-width:28rem}.max-w-\[30rem\]{max-width:30rem}.max-w-\[36rem\]{max-width:36rem}.max-w-\[40vw\]{max-width:40vw}.max-w-\[42ch\]{max-width:42ch}.max-w-\[48rem\]{max-width:48rem}.max-w-\[50\%\]{max-width:50%}.max-w-\[50vw\]{max-width:50vw}.max-w-\[58vw\]{max-width:58vw}.max-w-\[60\%\]{max-width:60%}.max-w-\[70\%\]{max-width:70%}.max-w-\[70dvw\]{max-width:70dvw}.max-w-\[75\%\]{max-width:75%}.max-w-\[75dvw\]{max-width:75dvw}.max-w-\[75vw\]{max-width:75vw}.max-w-\[80\%\]{max-width:80%}.max-w-\[80vw\]{max-width:80vw}.max-w-\[80vw\]\!{max-width:80vw!important}.max-w-\[86\%\]{max-width:86%}.max-w-\[88px\]{max-width:88px}.max-w-\[90\%\]{max-width:90%}.max-w-\[90vw\]{max-width:90vw}.max-w-\[94rem\]{max-width:94rem}.max-w-\[95\%\]{max-width:95%}.max-w-\[95vw\]{max-width:95vw}.max-w-\[96rem\]{max-width:96rem}.max-w-\[100px\]{max-width:100px}.max-w-\[100vw\]{max-width:100vw}.max-w-\[120px\]{max-width:120px}.max-w-\[130px\]{max-width:130px}.max-w-\[140px\]{max-width:140px}.max-w-\[150px\]{max-width:150px}.max-w-\[160px\]{max-width:160px}.max-w-\[180px\]{max-width:180px}.max-w-\[200px\]{max-width:200px}.max-w-\[210px\]{max-width:210px}.max-w-\[212px\]{max-width:212px}.max-w-\[220px\]{max-width:220px}.max-w-\[240px\]{max-width:240px}.max-w-\[248px\]{max-width:248px}.max-w-\[250px\]{max-width:250px}.max-w-\[260px\]{max-width:260px}.max-w-\[264px\]{max-width:264px}.max-w-\[270px\]{max-width:270px}.max-w-\[280px\]{max-width:280px}.max-w-\[286px\]{max-width:286px}.max-w-\[300px\]{max-width:300px}.max-w-\[310px\]{max-width:310px}.max-w-\[318px\]{max-width:318px}.max-w-\[320px\]{max-width:320px}.max-w-\[330px\]{max-width:330px}.max-w-\[340px\]{max-width:340px}.max-w-\[350px\]{max-width:350px}.max-w-\[360px\]{max-width:360px}.max-w-\[373px\]{max-width:373px}.max-w-\[390px\]{max-width:390px}.max-w-\[400px\]{max-width:400px}.max-w-\[404px\]{max-width:404px}.max-w-\[412px\]{max-width:412px}.max-w-\[416px\]{max-width:416px}.max-w-\[420px\]{max-width:420px}.max-w-\[428px\]{max-width:428px}.max-w-\[440px\]{max-width:440px}.max-w-\[443px\]{max-width:443px}.max-w-\[448px\]{max-width:448px}.max-w-\[460px\]{max-width:460px}.max-w-\[465px\]{max-width:465px}.max-w-\[468px\]{max-width:468px}.max-w-\[480px\]{max-width:480px}.max-w-\[500px\]{max-width:500px}.max-w-\[512px\]{max-width:512px}.max-w-\[515px\]{max-width:515px}.max-w-\[520px\]{max-width:520px}.max-w-\[524px\]\!{max-width:524px!important}.max-w-\[525px\]{max-width:525px}.max-w-\[536px\]{max-width:536px}.max-w-\[540px\]{max-width:540px}.max-w-\[540px\]\!{max-width:540px!important}.max-w-\[544px\]{max-width:544px}.max-w-\[548px\]{max-width:548px}.max-w-\[550px\]{max-width:550px}.max-w-\[550px\]\!{max-width:550px!important}.max-w-\[552px\]{max-width:552px}.max-w-\[555px\]{max-width:555px}.max-w-\[560px\]{max-width:560px}.max-w-\[580px\]{max-width:580px}.max-w-\[596px\]{max-width:596px}.max-w-\[600px\]{max-width:600px}.max-w-\[620px\]{max-width:620px}.max-w-\[640px\]{max-width:640px}.max-w-\[640px\]\!{max-width:640px!important}.max-w-\[650px\]{max-width:650px}.max-w-\[664px\]{max-width:664px}.max-w-\[680px\]{max-width:680px}.max-w-\[680px\]\!{max-width:680px!important}.max-w-\[700px\]{max-width:700px}.max-w-\[720px\]{max-width:720px}.max-w-\[728px\]{max-width:728px}.max-w-\[748px\]{max-width:748px}.max-w-\[760px\]{max-width:760px}.max-w-\[768px\]{max-width:768px}.max-w-\[780px\]{max-width:780px}.max-w-\[780px\]\!{max-width:780px!important}.max-w-\[800px\]{max-width:800px}.max-w-\[820px\]{max-width:820px}.max-w-\[840px\]{max-width:840px}.max-w-\[896px\]{max-width:896px}.max-w-\[900px\]{max-width:900px}.max-w-\[905px\]{max-width:905px}.max-w-\[920px\]{max-width:920px}.max-w-\[960px\]{max-width:960px}.max-w-\[980px\]{max-width:980px}.max-w-\[1000px\]{max-width:1000px}.max-w-\[1024px\]{max-width:1024px}.max-w-\[1040px\]{max-width:1040px}.max-w-\[1060px\]{max-width:1060px}.max-w-\[1120px\]{max-width:1120px}.max-w-\[1180px\]{max-width:1180px}.max-w-\[1200px\]{max-width:1200px}.max-w-\[1236px\]{max-width:1236px}.max-w-\[1240px\]{max-width:1240px}.max-w-\[1248px\]{max-width:1248px}.max-w-\[1280px\]{max-width:1280px}.max-w-\[1300px\]{max-width:1300px}.max-w-\[1600px\]{max-width:1600px}.max-w-\[1800px\]{max-width:1800px}.max-w-\[1900px\]{max-width:1900px}.max-w-\[calc\(\(100cqw-var\(--true-content-width\)\)\/2\)\]{max-width:calc((100cqw - var(--true-content-width))/2)}.max-w-\[calc\(0\.8\*var\(--thread-content-max-width\,40rem\)\)\]{max-width:calc(.8*var(--thread-content-max-width,40rem))}.max-w-\[calc\(2\*var\(--thread-content-max-width\)\)\]{max-width:calc(2*var(--thread-content-max-width))}.max-w-\[calc\(100\%-1rem\)\]{max-width:calc(100% - 1rem)}.max-w-\[calc\(100\%-30px\)\]{max-width:calc(100% - 30px)}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-\[calc\(100vw-16px\)\]{max-width:calc(100vw - 16px)}.max-w-\[calc\(100vw-20px\)\]{max-width:calc(100vw - 20px)}.max-w-\[calc\(100vw-24px\)\]{max-width:calc(100vw - 24px)}.max-w-\[calc\(100vw-32px\)\]{max-width:calc(100vw - 32px)}.max-w-\[calc\(100vw-48px\)\]{max-width:calc(100vw - 48px)}.max-w-\[calc\(100vw-64px\)\]\!{max-width:calc(100vw - 64px)!important}.max-w-\[calc\(var\(--breakpoint-xl\)\*6\/12\)\]{max-width:calc(var(--breakpoint-xl)*6/12)}.max-w-\[calc\(var\(--breakpoint-xl\)\*8\/12\)\]{max-width:calc(var(--breakpoint-xl)*8/12)}.max-w-\[calc\(var\(--breakpoint-xl\)\*10\/12\)\]{max-width:calc(var(--breakpoint-xl)*10/12)}.max-w-\[min\(32rem\,calc\(100vw-2\.5rem\)\)\]{max-width:min(32rem,100vw - 2.5rem)}.max-w-\[min\(70vw\,520px\)\]{max-width:min(70vw,520px)}.max-w-\[min\(420px\,95dvw\)\]{max-width:min(420px,95dvw)}.max-w-\[min\(480px\,95dvw\)\]{max-width:min(480px,95dvw)}.max-w-\[min\(780px\,95dvw\)\]{max-width:min(780px,95dvw)}.max-w-\[min\(792px\,95dvw\)\]{max-width:min(792px,95dvw)}.max-w-\[min\(var\(--radix-popper-available-width\,100vw\)\,--spacing\(100\)\)\]{max-width:min(var(--radix-popper-available-width,100vw),calc(var(--spacing)*100))}.max-w-\[unset\]\!{max-width:unset!important}.max-w-\[var\(--radix-dropdown-menu-trigger-width\)\]{max-width:var(--radix-dropdown-menu-trigger-width)}.max-w-\[var\(--thread-content-max-width\,40rem\)\]{max-width:var(--thread-content-max-width,40rem)}.max-w-\[var\(--user-chat-width\,70\%\)\]{max-width:var(--user-chat-width,70%)}.max-w-app-content{max-width:min(100cqw,800px)}.max-w-fit{max-width:-webkit-fit-content;max-width:fit-content}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-max{max-width:-webkit-max-content;max-width:max-content}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-none\!{max-width:none!important}.max-w-prose{max-width:65ch}.max-w-screen{max-width:100vw}.max-w-screen-2xl{max-width:var(--breakpoint-2xl)}.max-w-screen-lg{max-width:var(--breakpoint-lg)}.max-w-screen-md{max-width:var(--breakpoint-md)}.max-w-screen-xl{max-width:var(--breakpoint-xl)}.max-w-screen-xs{max-width:480px}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.\!min-w-\[72px\]{min-width:72px!important}.\!min-w-\[84px\]{min-width:84px!important}.min-w-\(--file-tile-width\){min-width:var(--file-tile-width)}.min-w-\(--sidebar-width\){min-width:var(--sidebar-width)}.min-w-\(--thread-content-width\){min-width:var(--thread-content-width)}.min-w-\(--trigger-width\){min-width:var(--trigger-width)}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-2{min-width:calc(var(--spacing)*2)}.min-w-2xl{min-width:var(--container-2xl)}.min-w-3{min-width:calc(var(--spacing)*3)}.min-w-3xl{min-width:var(--container-3xl)}.min-w-4{min-width:calc(var(--spacing)*4)}.min-w-4\/5{min-width:80%}.min-w-5{min-width:calc(var(--spacing)*5)}.min-w-6{min-width:calc(var(--spacing)*6)}.min-w-7{min-width:calc(var(--spacing)*7)}.min-w-8{min-width:calc(var(--spacing)*8)}.min-w-8\!{min-width:calc(var(--spacing)*8)!important}.min-w-9{min-width:calc(var(--spacing)*9)}.min-w-10{min-width:calc(var(--spacing)*10)}.min-w-11{min-width:calc(var(--spacing)*11)}.min-w-12{min-width:calc(var(--spacing)*12)}.min-w-16{min-width:calc(var(--spacing)*16)}.min-w-20{min-width:calc(var(--spacing)*20)}.min-w-24{min-width:calc(var(--spacing)*24)}.min-w-25{min-width:calc(var(--spacing)*25)}.min-w-28{min-width:calc(var(--spacing)*28)}.min-w-30{min-width:calc(var(--spacing)*30)}.min-w-32{min-width:calc(var(--spacing)*32)}.min-w-36{min-width:calc(var(--spacing)*36)}.min-w-40{min-width:calc(var(--spacing)*40)}.min-w-44{min-width:calc(var(--spacing)*44)}.min-w-45{min-width:calc(var(--spacing)*45)}.min-w-48{min-width:calc(var(--spacing)*48)}.min-w-60{min-width:calc(var(--spacing)*60)}.min-w-64{min-width:calc(var(--spacing)*64)}.min-w-66{min-width:calc(var(--spacing)*66)}.min-w-80{min-width:calc(var(--spacing)*80)}.min-w-96{min-width:calc(var(--spacing)*96)}.min-w-100{min-width:25rem}.min-w-\[1\.5rem\]{min-width:1.5rem}.min-w-\[1em\]{min-width:1em}.min-w-\[2\.75rem\]{min-width:2.75rem}.min-w-\[2ch\]{min-width:2ch}.min-w-\[2em\]{min-width:2em}.min-w-\[4\.5rem\]{min-width:4.5rem}.min-w-\[4rem\]{min-width:4rem}.min-w-\[6rem\]{min-width:6rem}.min-w-\[7\.5rem\]{min-width:7.5rem}.min-w-\[9\.5rem\]{min-width:9.5rem}.min-w-\[11rem\]{min-width:11rem}.min-w-\[16px\]{min-width:16px}.min-w-\[16rem\]{min-width:16rem}.min-w-\[25vw\]{min-width:25vw}.min-w-\[32px\]{min-width:32px}.min-w-\[40px\]{min-width:40px}.min-w-\[44px\]{min-width:44px}.min-w-\[50\%\]{min-width:50%}.min-w-\[50px\]{min-width:50px}.min-w-\[56px\]{min-width:56px}.min-w-\[56rem\]{min-width:56rem}.min-w-\[60px\]{min-width:60px}.min-w-\[62px\]{min-width:62px}.min-w-\[64px\]{min-width:64px}.min-w-\[70px\]{min-width:70px}.min-w-\[72px\]{min-width:72px}.min-w-\[78px\]{min-width:78px}.min-w-\[80px\]{min-width:80px}.min-w-\[84px\]{min-width:84px}.min-w-\[88px\]{min-width:88px}.min-w-\[88px\]\!{min-width:88px!important}.min-w-\[92px\]{min-width:92px}.min-w-\[96px\]{min-width:96px}.min-w-\[100px\]{min-width:100px}.min-w-\[108px\]{min-width:108px}.min-w-\[110px\]{min-width:110px}.min-w-\[114px\]{min-width:114px}.min-w-\[116px\]{min-width:116px}.min-w-\[120px\]{min-width:120px}.min-w-\[130px\]{min-width:130px}.min-w-\[136px\]{min-width:136px}.min-w-\[140px\]{min-width:140px}.min-w-\[150px\]{min-width:150px}.min-w-\[155px\]{min-width:155px}.min-w-\[160px\]{min-width:160px}.min-w-\[165px\]{min-width:165px}.min-w-\[170px\]{min-width:170px}.min-w-\[174px\]{min-width:174px}.min-w-\[180px\]{min-width:180px}.min-w-\[190px\]{min-width:190px}.min-w-\[200px\]{min-width:200px}.min-w-\[210px\]{min-width:210px}.min-w-\[220px\]{min-width:220px}.min-w-\[224px\]{min-width:224px}.min-w-\[240px\]{min-width:240px}.min-w-\[260px\]{min-width:260px}.min-w-\[280px\]{min-width:280px}.min-w-\[284px\]{min-width:284px}.min-w-\[300px\]{min-width:300px}.min-w-\[300px\]\!{min-width:300px!important}.min-w-\[320px\]{min-width:320px}.min-w-\[360px\]{min-width:360px}.min-w-\[400px\]{min-width:400px}.min-w-\[420px\]{min-width:420px}.min-w-\[520px\]{min-width:520px}.min-w-\[640px\]{min-width:640px}.min-w-\[680px\]{min-width:680px}.min-w-\[720px\]{min-width:720px}.min-w-\[760px\]{min-width:760px}.min-w-\[920px\]{min-width:920px}.min-w-\[calc\(100\%\/3\)\]{min-width:33.3333%}.min-w-\[calc\(var\(--sidebar-width\)-12px\)\]{min-width:calc(var(--sidebar-width) - 12px)}.min-w-\[calc\(var\(--sidebar-width\)-14px\)\]{min-width:calc(var(--sidebar-width) - 14px)}.min-w-\[max\(var\(--trigger-width\)\,min\(125px\,95vw\)\)\]{min-width:max(var(--trigger-width),min(125px,95vw))}.min-w-\[max\(var\(--trigger-width\)\,min\(200px\,95vw\)\)\]{min-width:max(var(--trigger-width),min(200px,95vw))}.min-w-\[max\(var\(--trigger-width\)\,min\(280px\,95vw\)\)\]{min-width:max(var(--trigger-width),min(280px,95vw))}.min-w-\[max\(var\(--trigger-width\)\,min\(350px\,95vw\)\)\]{min-width:max(var(--trigger-width),min(350px,95vw))}.min-w-\[min\(12rem\,100\%\)\]{min-width:min(12rem,100%)}.min-w-\[min\(36rem\,calc\(0\.8\*var\(--thread-content-max-width\,40rem\)\)\)\]{min-width:min(36rem,calc(.8*var(--thread-content-max-width,40rem)))}.min-w-\[min\(360px\,94\%\)\]{min-width:min(360px,94%)}.min-w-\[min\(450px\,80cqw\,80vw\)\]{min-width:min(450px,80cqw,80vw)}.min-w-\[var\(--radix-dropdown-menu-trigger-width\)\]{min-width:var(--radix-dropdown-menu-trigger-width)}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.min-w-\[var\(--radix-select-trigger-width\)\]\!{min-width:var(--radix-select-trigger-width)!important}.min-w-bloop{min-width:227px}.min-w-fit{min-width:-webkit-fit-content;min-width:fit-content}.min-w-full{min-width:100%}.min-w-max{min-width:-webkit-max-content;min-width:max-content}.min-w-min{min-width:-webkit-min-content;min-width:min-content}.min-w-px{min-width:1px}.min-w-sm{min-width:var(--container-sm)}.min-w-xs{min-width:var(--container-xs)}.flex-\(--composer-container-flex\,1\){flex:var(--composer-container-flex,1)}.flex-0{flex:0}.flex-1{flex:1}.flex-\[0_0_100\%\]{flex:0 0 100%}.flex-\[0_1_140px\]{flex:0 140px}.flex-\[1_0_0\]{flex:1 0 0}.flex-\[2\]{flex:2}.flex-auto{flex:auto}.flex-initial{flex:0 auto}.flex-none{flex:none}.badge-base{border-radius:var(--radius-3xl);padding-inline:calc(var(--spacing)*2);--tw-leading:15px;--tw-font-weight:var(--font-weight-semibold);font-size:10px;line-height:15px;font-weight:var(--font-weight-semibold);text-transform:uppercase;flex-shrink:0;padding-top:1px;padding-bottom:1px}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.flex-shrink-1,.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.shrink-1{flex-shrink:1}.flex-grow{flex-grow:1}.flex-grow-0{flex-grow:0}.flex-grow-1,.grow{flex-grow:1}.grow-0{flex-grow:0}.basis-0{flex-basis:calc(var(--spacing)*0)}.basis-\[32px\]{flex-basis:32px}.basis-\[calc\(\(100\%-2rem\)\/2\)\]{flex-basis:calc(50% - 1rem)}.basis-\[calc\(\(100\%-2rem\)\/3\)\]{flex-basis:calc(33.3333% - .666667rem)}.basis-\[calc\(\(100\%-3rem\)\/3\.5\)\]{flex-basis:calc(28.5714% - .857143rem)}.basis-\[calc\(\(100\%-3rem\)\/4\)\]{flex-basis:calc(25% - .75rem)}.basis-\[calc\(\(100\%-4rem\)\/4\.5\)\]{flex-basis:calc(22.2222% - .888889rem)}.basis-\[calc\(\(100\%-4rem\)\/5\)\]{flex-basis:calc(20% - .8rem)}.basis-\[calc\(50\%-0\.375rem\)\]{flex-basis:calc(50% - .375rem)}.basis-auto{flex-basis:auto}.basis-full{flex-basis:100%}.table-auto{table-layout:auto}.table-fixed{table-layout:fixed}.border-collapse{border-collapse:collapse}.border-separate{border-collapse:separate}.border-spacing-0{--tw-border-spacing-x:calc(var(--spacing)*0);--tw-border-spacing-y:calc(var(--spacing)*0);border-spacing:var(--tw-border-spacing-x)var(--tw-border-spacing-y)}.border-spacing-4{--tw-border-spacing-x:calc(var(--spacing)*4);--tw-border-spacing-y:calc(var(--spacing)*4);border-spacing:var(--tw-border-spacing-x)var(--tw-border-spacing-y)}.border-spacing-y-2{--tw-border-spacing-y:calc(var(--spacing)*2);border-spacing:var(--tw-border-spacing-x)var(--tw-border-spacing-y)}.flip{transform-origin:50%;--tw-scale-x:-1;scale:var(--tw-scale-x)var(--tw-scale-y)}.flip\!{transform-origin:50%!important;--tw-scale-x:-1!important;scale:var(--tw-scale-x)var(--tw-scale-y)!important}.origin-\[14px_50\%\]{transform-origin:14px}.origin-\[50\%_50\%\]{transform-origin:50%}.origin-\[bottom_var\(--start\)\]{transform-origin:bottom var(--start)}.origin-bottom{transform-origin:bottom}.origin-center{transform-origin:50%}.origin-right{transform-origin:100%}.origin-start{transform-origin:var(--start)}.origin-top-left{transform-origin:0 0}.translate-\[-50\%\]{--tw-translate-x:-50%;--tw-translate-y:-50%;translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-0\.5{--tw-translate-x:calc(var(--spacing)*-.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-1{--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-1\.5{--tw-translate-x:calc(var(--spacing)*-1.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-1\/2{--tw-translate-x:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-1\/3{--tw-translate-x:calc(calc(1/3*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-2{--tw-translate-x:calc(var(--spacing)*-2);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-3{--tw-translate-x:calc(var(--spacing)*-3);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-52{--tw-translate-x:calc(var(--spacing)*-52);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-96{--tw-translate-x:calc(var(--spacing)*-96);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-\[0\.16px\]{--tw-translate-x:calc(.16px*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-\[30\%\]{--tw-translate-x:calc(30%*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-\[200vw\]{--tw-translate-x:calc(200vw*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0\.5{--tw-translate-x:calc(var(--spacing)*.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-1{--tw-translate-x:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-1\/2{--tw-translate-x:calc(1/2*100%);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-2{--tw-translate-x:calc(var(--spacing)*2);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-3{--tw-translate-x:calc(var(--spacing)*3);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-5{--tw-translate-x:calc(var(--spacing)*5);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-6{--tw-translate-x:calc(var(--spacing)*6);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-52{--tw-translate-x:calc(var(--spacing)*52);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-96{--tw-translate-x:calc(var(--spacing)*96);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-\[1px\]{--tw-translate-x:1px;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-\[4\%\]{--tw-translate-x:4%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-\[50\%\]{--tw-translate-x:50%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-\[calc\(-50\%-125px\)\]{--tw-translate-x:calc(-50% - 125px);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-\[calc\(-50\%-135px\)\]{--tw-translate-x:calc(-50% - 135px);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-px{--tw-translate-x:1px;translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-0\.5{--tw-translate-y:calc(var(--spacing)*-.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1\/3{--tw-translate-y:calc(calc(1/3*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-2{--tw-translate-y:calc(var(--spacing)*-2);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-3{--tw-translate-y:calc(var(--spacing)*-3);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-6{--tw-translate-y:calc(var(--spacing)*-6);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-8{--tw-translate-y:calc(var(--spacing)*-8);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-9{--tw-translate-y:calc(var(--spacing)*-9);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-12{--tw-translate-y:calc(var(--spacing)*-12);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-\[0\.1px\]{--tw-translate-y:calc(.1px*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-\[2\.5rem\]{--tw-translate-y:calc(2.5rem*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-\[6px\]{--tw-translate-y:calc(6px*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-\[50\%\]{--tw-translate-y:calc(50%*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-\[55\%\]{--tw-translate-y:calc(55%*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-\[200\%\]{--tw-translate-y:calc(200%*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-\[calc\(env\(safe-area-inset-bottom\,0px\)\/2\)\]{--tw-translate-y:calc(calc(env(safe-area-inset-bottom,0px)/2)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-full{--tw-translate-y:-100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-0{--tw-translate-y:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-0\.5{--tw-translate-y:calc(var(--spacing)*.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-1{--tw-translate-y:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing)*2);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-3{--tw-translate-y:calc(var(--spacing)*3);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-4{--tw-translate-y:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-10{--tw-translate-y:calc(var(--spacing)*10);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[-85\%\]{--tw-translate-y:-85%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[-100\%\]{--tw-translate-y:-100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[-135\%\]{--tw-translate-y:-135%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[0\.5px\]{--tw-translate-y:.5px;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[1px\]{--tw-translate-y:1px;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[2\.5rem\]{--tw-translate-y:2.5rem;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[2\.25px\]{--tw-translate-y:2.25px;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[2px\]{--tw-translate-y:2px;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[4\%\]{--tw-translate-y:4%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[4px\]{--tw-translate-y:4px;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[6px\]{--tw-translate-y:6px;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[20px\]{--tw-translate-y:20px;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[100\%\]{--tw-translate-y:100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[calc\(-50\%\+70px\)\]{--tw-translate-y:calc(-50% + 70px);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[calc\(-50\%\+75px\)\]{--tw-translate-y:calc(-50% + 75px);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[calc\(-50\%-50px\)\]{--tw-translate-y:calc(-50% - 50px);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[calc\(-50\%-60px\)\]{--tw-translate-y:calc(-50% - 60px);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-full{--tw-translate-y:100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-px{--tw-translate-y:1px;translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-75{--tw-scale-x:75%;--tw-scale-y:75%;--tw-scale-z:75%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-80{--tw-scale-x:80%;--tw-scale-y:80%;--tw-scale-z:80%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-90{--tw-scale-x:90%;--tw-scale-y:90%;--tw-scale-z:90%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-105{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-110{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-150{--tw-scale-x:150%;--tw-scale-y:150%;--tw-scale-z:150%;scale:var(--tw-scale-x)var(--tw-scale-y)}.-scale-x-100{--tw-scale-x:calc(100%*-1);scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-x-0{--tw-scale-x:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-x-75{--tw-scale-x:75%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-x-100{--tw-scale-x:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-x-\[0\.5\]{--tw-scale-x:.5;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-\[0\.9\]{scale:.9}.scale-\[0\.85\]{scale:.85}.scale-\[0\.92\]{scale:.92}.scale-\[0\.98\]{scale:.98}.scale-\[1\.01\]{scale:1.01}.-rotate-3{rotate:-3deg}.-rotate-4{rotate:-4deg}.-rotate-5{rotate:-5deg}.-rotate-13{rotate:-13deg}.-rotate-15{rotate:-15deg}.-rotate-90{rotate:-90deg}.-rotate-180{rotate:-180deg}.-rotate-\[1deg\]{rotate:-1deg}.-rotate-\[2deg\]{rotate:-2deg}.-rotate-\[4deg\]{rotate:-4deg}.-rotate-\[5deg\]{rotate:-5deg}.-rotate-\[6\.5deg\]{rotate:-6.5deg}.-rotate-\[7deg\]{rotate:-7deg}.-rotate-\[12deg\]{rotate:-12deg}.rotate-0{rotate:none}.rotate-4{rotate:4deg}.rotate-5{rotate:5deg}.rotate-7{rotate:7deg}.rotate-10{rotate:10deg}.rotate-15{rotate:15deg}.rotate-45{rotate:45deg}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.rotate-\[-1deg\]{rotate:-1deg}.rotate-\[-5deg\]{rotate:-5deg}.rotate-\[-10deg\]{rotate:-10deg}.rotate-\[-11\.67deg\]{rotate:-11.67deg}.rotate-\[-90deg\]{rotate:-90deg}.rotate-\[1deg\]{rotate:1deg}.rotate-\[3\.61deg\]{rotate:3.61deg}.rotate-\[3deg\]{rotate:3deg}.rotate-\[5deg\]{rotate:5deg}.rotate-\[9\.42deg\]{rotate:9.42deg}.rotate-\[9deg\]{rotate:9deg}.-skew-x-12{--tw-skew-x:skewX(calc(12deg*-1));transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.\[transform\:translateZ\(0\)\]{transform:translateZ(0)}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.transform\!{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)!important}.transform-\[translate3d\(0\,0\,0\)\]{transform:translate(0)}.transform-gpu{transform:translateZ(0)var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.\[animation\:pulseSize_1\.25s_infinite_ease-in-out\]{animation:1.25s ease-in-out infinite pulseSize}.animate-\[hive-log-fadeout_0\.3s_1\.5s_forwards\]{animation:.3s 1.5s forwards hive-log-fadeout}.animate-\[show_150ms_ease-in\]{animation:.15s ease-in show}.animate-\[slide-in-left_0\.2s_forwards\]{animation:.2s forwards slide-in-left}.animate-\[slide-in-right_0\.2s_forwards\]{animation:.2s forwards slide-in-right}.animate-\[slide-out-left_0\.2s_forwards\]{animation:.2s forwards slide-out-left}.animate-\[slide-out-right_0\.2s_forwards\]{animation:.2s forwards slide-out-right}.animate-bounce{animation:var(--animate-bounce)}.animate-none{animation:none}.animate-pulse{animation:var(--animate-pulse)}.animate-pulsing{animation:2s ease-in-out infinite forwards pulsing}.animate-show{animation:.1s cubic-bezier(.16,1,.3,1) show}.animate-slideLeftAndFade{animation:.2s cubic-bezier(.16,1,.3,1) slideLeftAndFade}.animate-spin{animation:var(--animate-spin)}.sdtrn-root{font-family:Segoe UI Variable Text,"ui-sans-serif",-apple-system,"system-ui",Segoe UI,Helvetica,Apple Color Emoji,Arial,"sans-serif",Segoe UI Emoji,Segoe UI Symbol}.sdtrn-root .text-xs{font-family:Segoe UI Variable Small,"ui-sans-serif",-apple-system,"system-ui",Segoe UI,Helvetica,Apple Color Emoji,Arial,"sans-serif",Segoe UI Emoji,Segoe UI Symbol;font-size:12px;line-height:16px}.sdtrn-root .text-sm,.sdtrn-root .text-base,.sdtrn-root .text-body{font-family:Segoe UI Variable Text,"ui-sans-serif",-apple-system,"system-ui",Segoe UI,Helvetica,Apple Color Emoji,Arial,"sans-serif",Segoe UI Emoji,Segoe UI Symbol;font-size:14px;line-height:20px}.sdtrn-root .text-lg{font-family:Segoe UI Variable Text,"ui-sans-serif",-apple-system,"system-ui",Segoe UI,Helvetica,Apple Color Emoji,Arial,"sans-serif",Segoe UI Emoji,Segoe UI Symbol}.sdtrn-root .text-xl,.sdtrn-root .text-2xl,.sdtrn-root .text-3xl{font-family:Segoe UI Variable Display,"ui-sans-serif",-apple-system,"system-ui",Segoe UI,Helvetica,Apple Color Emoji,Arial,"sans-serif",Segoe UI Emoji,Segoe UI Symbol}.sdtrn-root .cursor-pointer{cursor:default;-webkit-user-select:none;user-select:none}.cursor-auto{cursor:auto}.cursor-col-resize{cursor:col-resize}.cursor-crosshair{cursor:crosshair}.cursor-default{cursor:default}.cursor-default\!{cursor:default!important}.cursor-e-resize{cursor:e-resize}.cursor-ew-resize{cursor:ew-resize}.cursor-grab{cursor:grab}.cursor-grabbing{cursor:grabbing}.cursor-help{cursor:help}.cursor-move{cursor:move}.cursor-none{cursor:none}.cursor-not-allowed{cursor:not-allowed}.cursor-ns-resize{cursor:ns-resize}.cursor-pointer{cursor:pointer}.cursor-row-resize{cursor:row-resize}.cursor-text{cursor:text}.cursor-text\!{cursor:text!important}.cursor-w-resize{cursor:w-resize}.cursor-wait{cursor:wait}.cursor-zoom-in{cursor:zoom-in}.cursor-zoom-out{cursor:zoom-out}.touch-pan-x{--tw-pan-x:pan-x;touch-action:var(--tw-pan-x,)var(--tw-pan-y,)var(--tw-pinch-zoom,)}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,)var(--tw-pan-y,)var(--tw-pinch-zoom,)}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.resize-y{resize:vertical}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-y{scroll-snap-type:y var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-proximity{--tw-scroll-snap-strictness:proximity}.snap-center{scroll-snap-align:center}.snap-end{scroll-snap-align:end}.snap-start{scroll-snap-align:start}.\[scroll-snap-stop\:always\],.snap-always{scroll-snap-stop:always}.scroll-m-4{scroll-margin:calc(var(--spacing)*4)}.scroll-m-5{scroll-margin:calc(var(--spacing)*5)}.scroll-m-mkt-header-height{scroll-margin:var(--mkt-header-height)}.scroll-mx-5{scroll-margin-inline:calc(var(--spacing)*5)}.scroll-mt-\(--header-height\){scroll-margin-top:var(--header-height)}.scroll-mt-2{scroll-margin-top:calc(var(--spacing)*2)}.scroll-mt-6{scroll-margin-top:calc(var(--spacing)*6)}.scroll-mt-24{scroll-margin-top:calc(var(--spacing)*24)}.scroll-mt-28{scroll-margin-top:calc(var(--spacing)*28)}.scroll-mt-\[calc\(var\(--header-height\)\+min\(200px\,max\(70px\,20svh\)\)\)\]{scroll-margin-top:calc(var(--header-height) + min(200px,max(70px,20svh)))}.scroll-mt-mkt-header-height{scroll-margin-top:var(--mkt-header-height)}.scroll-mb-4{scroll-margin-bottom:calc(var(--spacing)*4)}.scroll-mb-25{scroll-margin-bottom:calc(var(--spacing)*25)}.scroll-ps-4{scroll-padding-inline-start:calc(var(--spacing)*4)}.scroll-ps-5{scroll-padding-inline-start:calc(var(--spacing)*5)}.scroll-pe-5{scroll-padding-inline-end:calc(var(--spacing)*5)}.scroll-pt-\(--header-height\){scroll-padding-top:var(--header-height)}.scroll-pt-3{scroll-padding-top:calc(var(--spacing)*3)}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.form-textarea{-webkit-appearance:none;appearance:none;border-color:var(--gray-500);--tw-shadow:0 0 transparent;background-color:#fff;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-textarea:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:var(--blue-600);--tw-ring-offset-shadow:var(--tw-ring-inset)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:var(--blue-600);outline:2px solid #0000}.form-textarea::placeholder{color:var(--gray-500);opacity:1}.\[appearance\:none\]{-webkit-appearance:none;appearance:none}.\[appearance\:textfield\]{-webkit-appearance:textfield;appearance:textfield}.appearance-none{-webkit-appearance:none;appearance:none}.columns-1{columns:1}.columns-2{columns:2}.break-inside-avoid{break-inside:avoid}.auto-cols-\[50\%\]{grid-auto-columns:50%}.auto-cols-fr{grid-auto-columns:minmax(0,1fr)}.grid-flow-col{grid-auto-flow:column}.grid-flow-dense{grid-auto-flow:dense}.grid-flow-row{grid-auto-flow:row}.\[grid-auto-rows\:1fr\]{grid-auto-rows:1fr}.\[grid-auto-rows\:minmax\(min-content\,auto\)\]{grid-auto-rows:minmax(min-content,auto)}.auto-rows-fr{grid-auto-rows:minmax(0,1fr)}.auto-rows-min{grid-auto-rows:min-content}.\!grid-cols-\[0px_1fr_0px\]{grid-template-columns:0 1fr 0!important}.\!grid-cols-\[28px_1fr_28px\]{grid-template-columns:28px 1fr 28px!important}.\[grid-template-columns\:minmax\(0\,1fr\)_max-content\]{grid-template-columns:minmax(0,1fr) max-content}.\[grid-template-columns\:var\(--paragen-cols\)\]{grid-template-columns:var(--paragen-cols)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-\[0px_1fr_0px\]{grid-template-columns:0 1fr 0}.grid-cols-\[1fr_1fr_auto\]{grid-template-columns:1fr 1fr auto}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.grid-cols-\[1fr_auto_1fr\]{grid-template-columns:1fr auto 1fr}.grid-cols-\[1fr_max-content_1fr\]{grid-template-columns:1fr max-content 1fr}.grid-cols-\[1fr_min-content\]{grid-template-columns:1fr min-content}.grid-cols-\[4ch_1fr\]{grid-template-columns:4ch 1fr}.grid-cols-\[5fr_1fr\]{grid-template-columns:5fr 1fr}.grid-cols-\[7rem_minmax\(0\,1fr\)\]{grid-template-columns:7rem minmax(0,1fr)}.grid-cols-\[10px_1fr_10px\]{grid-template-columns:10px 1fr 10px}.grid-cols-\[20px_1fr\]{grid-template-columns:20px 1fr}.grid-cols-\[50\%_50\%\]{grid-template-columns:50% 50%}.grid-cols-\[76px_minmax\(0\,1fr\)_76px\]{grid-template-columns:76px minmax(0,1fr) 76px}.grid-cols-\[80px_180px_1fr\]{grid-template-columns:80px 180px 1fr}.grid-cols-\[80px_180px_180px_1fr\]{grid-template-columns:80px 180px 180px 1fr}.grid-cols-\[80px_220px_1fr\]{grid-template-columns:80px 220px 1fr}.grid-cols-\[180px_1fr_32px\]{grid-template-columns:180px 1fr 32px}.grid-cols-\[180px_160px_220px_1fr\]{grid-template-columns:180px 160px 220px 1fr}.grid-cols-\[180px_180px_1fr\]{grid-template-columns:180px 180px 1fr}.grid-cols-\[200px_1fr_1fr\]{grid-template-columns:200px 1fr 1fr}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.grid-cols-\[auto_1fr_auto\]{grid-template-columns:auto 1fr auto}.grid-cols-\[auto_2fr_3fr_1fr\]{grid-template-columns:auto 2fr 3fr 1fr}.grid-cols-\[auto_auto\]{grid-template-columns:auto auto}.grid-cols-\[auto_auto_1fr\]{grid-template-columns:auto auto 1fr}.grid-cols-\[auto_auto_1fr_auto\]{grid-template-columns:auto auto 1fr auto}.grid-cols-\[auto_auto_auto_1fr\]{grid-template-columns:auto auto auto 1fr}.grid-cols-\[auto_auto_auto_1fr_auto\]{grid-template-columns:auto auto auto 1fr auto}.grid-cols-\[auto_max-content\]{grid-template-columns:auto max-content}.grid-cols-\[auto_minmax\(0\,1fr\)\]{grid-template-columns:auto minmax(0,1fr)}.grid-cols-\[auto_minmax\(0\,1fr\)_72px_auto\]{grid-template-columns:auto minmax(0,1fr) 72px auto}.grid-cols-\[auto_minmax\(0\,1fr\)_auto\]{grid-template-columns:auto minmax(0,1fr) auto}.grid-cols-\[max-content_minmax\(0\,1fr\)\]{grid-template-columns:max-content minmax(0,1fr)}.grid-cols-\[minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr)}.grid-cols-\[minmax\(0\,1fr\)_5\.75rem\]{grid-template-columns:minmax(0,1fr) 5.75rem}.grid-cols-\[minmax\(0\,1fr\)_92px_auto\]{grid-template-columns:minmax(0,1fr) 92px auto}.grid-cols-\[minmax\(0\,1fr\)_120px\]{grid-template-columns:minmax(0,1fr) 120px}.grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.grid-cols-\[minmax\(0\,1fr\)_auto_auto\]{grid-template-columns:minmax(0,1fr) auto auto}.grid-cols-\[minmax\(0\,1fr\)_max-content\]{grid-template-columns:minmax(0,1fr) max-content}.grid-cols-\[minmax\(0\,1fr\)_max-content_max-content_max-content\]{grid-template-columns:minmax(0,1fr) max-content max-content max-content}.grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1fr\)_36px\]{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 36px}.grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,96px\)\]{grid-template-columns:minmax(0,1fr) minmax(0,96px)}.grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,112px\)\]{grid-template-columns:minmax(0,1fr) minmax(0,112px)}.grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,160px\)_minmax\(0\,88px\)_64px\]{grid-template-columns:minmax(0,1fr) minmax(0,160px) minmax(0,88px) 64px}.grid-cols-\[minmax\(0\,124px\)_minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,124px) minmax(0,1fr) auto}.grid-cols-\[minmax\(0\,160px\)_1fr\]{grid-template-columns:minmax(0,160px) 1fr}.grid-cols-\[minmax\(0\,184px\)_1fr\]{grid-template-columns:minmax(0,184px) 1fr}.grid-cols-\[minmax\(180px\,1\.2fr\)_minmax\(180px\,1fr\)_minmax\(180px\,1fr\)\]{grid-template-columns:minmax(180px,1.2fr) minmax(180px,1fr) minmax(180px,1fr)}.grid-cols-\[repeat\(2\,minmax\(0\,8\.5rem\)\)\]{grid-template-columns:repeat(2,minmax(0,8.5rem))}.grid-cols-\[repeat\(4\,max-content\)\]{grid-template-columns:repeat(4,max-content)}.grid-cols-\[repeat\(6\,36px\)\]{grid-template-columns:repeat(6,36px)}.grid-cols-\[repeat\(auto-fit\,minmax\(100px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(100px,1fr))}.grid-cols-\[repeat\(auto-fit\,minmax\(120px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(120px,1fr))}.grid-cols-\[repeat\(auto-fit\,minmax\(150px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}.grid-cols-\[repeat\(auto-fit\,minmax\(180px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(180px,1fr))}.grid-cols-\[repeat\(auto-fit\,minmax\(200px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}.grid-cols-\[repeat\(auto-fit\,minmax\(240px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}.grid-cols-\[repeat\(auto-fit\,minmax\(250px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(250px,1fr))}.grid-cols-subgrid{grid-template-columns:subgrid}.\!grid-rows-\[28px_1fr\]{grid-template-rows:28px 1fr!important}.\!grid-rows-\[32px_minmax\(0\,1fr\)_0px\]{grid-template-rows:32px minmax(0,1fr) 0!important}.grid-rows-1{grid-template-rows:repeat(1,minmax(0,1fr))}.grid-rows-2{grid-template-rows:repeat(2,minmax(0,1fr))}.grid-rows-3{grid-template-rows:repeat(3,minmax(0,1fr))}.grid-rows-\[0fr\]{grid-template-rows:0fr}.grid-rows-\[0px_auto_0px\]{grid-template-rows:0 auto 0}.grid-rows-\[1fr\]{grid-template-rows:1fr}.grid-rows-\[1fr_0\]{grid-template-rows:1fr 0}.grid-rows-\[1fr_1fr\]{grid-template-rows:1fr 1fr}.grid-rows-\[1fr_auto\]{grid-template-rows:1fr auto}.grid-rows-\[1fr_auto_1fr\]{grid-template-rows:1fr auto 1fr}.grid-rows-\[1fr_min-content\]\!{grid-template-rows:1fr min-content!important}.grid-rows-\[1fr_minmax\(0\,180px\)\]{grid-template-rows:1fr minmax(0,180px)}.grid-rows-\[10px_auto_minmax\(10px\,1fr\)\]{grid-template-rows:10px auto minmax(10px,1fr)}.grid-rows-\[36px_auto_minmax\(36px\,1fr\)\]{grid-template-rows:36px auto minmax(36px,1fr)}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.grid-rows-\[auto_1fr_24px\]{grid-template-rows:auto 1fr 24px}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.grid-rows-\[auto_auto_auto\]{grid-template-rows:auto auto auto}.grid-rows-\[auto_min-content_min-content\]{grid-template-rows:auto min-content min-content}.grid-rows-\[auto_minmax\(0\,1fr\)\]{grid-template-rows:auto minmax(0,1fr)}.grid-rows-\[min-content_1fr_min-content\]{grid-template-rows:min-content 1fr min-content}.grid-rows-\[min-content_min-content_1fr\]{grid-template-rows:min-content min-content 1fr}.grid-rows-\[min-content_min-content_1fr_min-content\]{grid-template-rows:min-content min-content 1fr min-content}.grid-rows-\[minmax\(0\,0fr\)\]{grid-template-rows:minmax(0,0fr)}.grid-rows-\[minmax\(0\,1fr\)\]{grid-template-rows:minmax(0,1fr)}.grid-rows-\[minmax\(0\,1fr\)_auto\]{grid-template-rows:minmax(0,1fr) auto}.grid-rows-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-rows:minmax(0,1fr) auto minmax(0,1fr)}.grid-rows-\[minmax\(0\,min-content\)_minmax\(0\,1fr\)_minmax\(0\,min-content\)\]{grid-template-rows:minmax(0,min-content) minmax(0,1fr) minmax(0,min-content)}.grid-rows-\[minmax\(10px\,1fr\)_auto_10px\]{grid-template-rows:minmax(10px,1fr) auto 10px}.grid-rows-\[minmax\(10px\,1fr\)_auto_minmax\(10px\,1fr\)\]{grid-template-rows:minmax(10px,1fr) auto minmax(10px,1fr)}.grid-rows-\[minmax\(10px\,auto\)_minmax\(200px\,1fr\)_minmax\(0\,auto\)_minmax\(min-content\,86px\)_minmax\(10px\,auto\)\]{grid-template-rows:minmax(10px,auto) minmax(200px,1fr) minmax(0,auto) minmax(min-content,86px) minmax(10px,auto)}.grid-rows-\[var\(--header-height\)_minmax\(0\,1fr\)\]{grid-template-rows:var(--header-height)minmax(0,1fr)}.grid-rows-subgrid{grid-template-rows:subgrid}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.place-content-center{place-content:center}.place-content-center-safe{place-content:safe center}.place-items-center{place-items:center}.place-items-start{place-items:start}.content-center{align-content:center}.\!items-center{align-items:center!important}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-center\!{align-items:center!important}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-start\!{align-items:flex-start!important}.items-stretch{align-items:stretch}.\!justify-between{justify-content:space-between!important}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-between\!{justify-content:space-between!important}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-end\!{justify-content:flex-end!important}.justify-evenly{justify-content:space-evenly}.justify-start{justify-content:flex-start}.justify-stretch{justify-content:stretch}.justify-items-center{justify-items:center}.justify-items-end{justify-items:end}.justify-items-start{justify-items:start}.gap-\(--image-page-spacing\){gap:var(--image-page-spacing)}.gap-0{gap:calc(var(--spacing)*0)}.gap-0\!{gap:calc(var(--spacing)*0)!important}.gap-0\.5{gap:calc(var(--spacing)*.5)}.gap-0\.25{gap:calc(var(--spacing)*.25)}.gap-0\.75{gap:calc(var(--spacing)*.75)}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\!{gap:calc(var(--spacing)*1)!important}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-2\!{gap:calc(var(--spacing)*2)!important}.gap-2\.5{gap:calc(var(--spacing)*2.5)}.gap-2\.5\!{gap:calc(var(--spacing)*2.5)!important}.gap-3{gap:calc(var(--spacing)*3)}.gap-3\!{gap:calc(var(--spacing)*3)!important}.gap-3\.5{gap:calc(var(--spacing)*3.5)}.gap-4{gap:calc(var(--spacing)*4)}.gap-5{gap:calc(var(--spacing)*5)}.gap-6{gap:calc(var(--spacing)*6)}.gap-7{gap:calc(var(--spacing)*7)}.gap-8{gap:calc(var(--spacing)*8)}.gap-9{gap:calc(var(--spacing)*9)}.gap-10{gap:calc(var(--spacing)*10)}.gap-12{gap:calc(var(--spacing)*12)}.gap-14{gap:calc(var(--spacing)*14)}.gap-16{gap:calc(var(--spacing)*16)}.gap-20{gap:calc(var(--spacing)*20)}.gap-28{gap:calc(var(--spacing)*28)}.gap-\[0\.3em\]{gap:.3em}.gap-\[2px\]{gap:2px}.gap-\[2rem\]{gap:2rem}.gap-\[3px\]{gap:3px}.gap-\[4px\]{gap:4px}.gap-\[5px\]{gap:5px}.gap-\[6px\]{gap:6px}.gap-\[9px\]{gap:9px}.gap-\[10px\]{gap:10px}.gap-\[12px\]{gap:12px}.gap-\[15px\]{gap:15px}.gap-\[16px\]{gap:16px}.gap-\[18px\]{gap:18px}.gap-\[22px\]{gap:22px}.gap-\[26px\]{gap:26px}.gap-\[28px\]{gap:28px}.gap-\[30px\]{gap:30px}.gap-\[32px\]{gap:32px}.gap-\[34px\]{gap:34px}.gap-\[42px\]{gap:42px}.gap-\[calc\(0\.3em\+4px\)\]{gap:calc(.3em + 4px)}.gap-\[min\(10dvw\,_200px\)\]{gap:min(10dvw,200px)}.gap-bar{gap:var(--bar-gap,.25rem)}.gap-px{gap:1px}.gap-snc-1{gap:var(--snc-1)}.gap-snc-results-padding{gap:var(--snc-results-padding)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*0)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*0)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*.5)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*1.5)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*1.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*2.5)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*2.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*5)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*10)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*10)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-12>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*12)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*12)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-\[2px\]>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(2px*var(--tw-space-y-reverse));margin-bottom:calc(2px*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}.gap-x-0{column-gap:calc(var(--spacing)*0)}.gap-x-1{column-gap:calc(var(--spacing)*1)}.gap-x-1\.5{column-gap:calc(var(--spacing)*1.5)}.gap-x-2{column-gap:calc(var(--spacing)*2)}.gap-x-3{column-gap:calc(var(--spacing)*3)}.gap-x-4{column-gap:calc(var(--spacing)*4)}.gap-x-5{column-gap:calc(var(--spacing)*5)}.gap-x-6{column-gap:calc(var(--spacing)*6)}.gap-x-8{column-gap:calc(var(--spacing)*8)}.gap-x-9{column-gap:calc(var(--spacing)*9)}.gap-x-10{column-gap:calc(var(--spacing)*10)}.gap-x-12{column-gap:calc(var(--spacing)*12)}.gap-x-\[4rem\]{column-gap:4rem}.gap-x-\[var\(--input-tag-spacing\)\]{column-gap:var(--input-tag-spacing)}:where(.-space-x-1\.5>:not(:last-child)){--tw-space-x-reverse:0}:where(.-space-x-1\.5>:not(:last-child)):dir(ltr){margin-left:calc(calc(var(--spacing)*-1.5)*var(--tw-space-x-reverse));margin-right:calc(calc(var(--spacing)*-1.5)*calc(1 - var(--tw-space-x-reverse)))}:where(.-space-x-1\.5>:not(:last-child)):dir(rtl){margin-right:calc(calc(var(--spacing)*-1.5)*var(--tw-space-x-reverse));margin-left:calc(calc(var(--spacing)*-1.5)*calc(1 - var(--tw-space-x-reverse)))}:where(.-space-x-2>:not(:last-child)){--tw-space-x-reverse:0}:where(.-space-x-2>:not(:last-child)):dir(ltr){margin-left:calc(calc(var(--spacing)*-2)*var(--tw-space-x-reverse));margin-right:calc(calc(var(--spacing)*-2)*calc(1 - var(--tw-space-x-reverse)))}:where(.-space-x-2>:not(:last-child)):dir(rtl){margin-right:calc(calc(var(--spacing)*-2)*var(--tw-space-x-reverse));margin-left:calc(calc(var(--spacing)*-2)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0}:where(.space-x-1>:not(:last-child)):dir(ltr){margin-left:calc(calc(var(--spacing)*1)*var(--tw-space-x-reverse));margin-right:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-1>:not(:last-child)):dir(rtl){margin-right:calc(calc(var(--spacing)*1)*var(--tw-space-x-reverse));margin-left:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0}:where(.space-x-2>:not(:last-child)):dir(ltr){margin-left:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-right:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)):dir(rtl){margin-right:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-left:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0}:where(.space-x-3>:not(:last-child)):dir(ltr){margin-left:calc(calc(var(--spacing)*3)*var(--tw-space-x-reverse));margin-right:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-3>:not(:last-child)):dir(rtl){margin-right:calc(calc(var(--spacing)*3)*var(--tw-space-x-reverse));margin-left:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0}:where(.space-x-4>:not(:last-child)):dir(ltr){margin-left:calc(calc(var(--spacing)*4)*var(--tw-space-x-reverse));margin-right:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)):dir(rtl){margin-right:calc(calc(var(--spacing)*4)*var(--tw-space-x-reverse));margin-left:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-12>:not(:last-child)){--tw-space-x-reverse:0}:where(.space-x-12>:not(:last-child)):dir(ltr){margin-left:calc(calc(var(--spacing)*12)*var(--tw-space-x-reverse));margin-right:calc(calc(var(--spacing)*12)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-12>:not(:last-child)):dir(rtl){margin-right:calc(calc(var(--spacing)*12)*var(--tw-space-x-reverse));margin-left:calc(calc(var(--spacing)*12)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-14>:not(:last-child)){--tw-space-x-reverse:0}:where(.space-x-14>:not(:last-child)):dir(ltr){margin-left:calc(calc(var(--spacing)*14)*var(--tw-space-x-reverse));margin-right:calc(calc(var(--spacing)*14)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-14>:not(:last-child)):dir(rtl){margin-right:calc(calc(var(--spacing)*14)*var(--tw-space-x-reverse));margin-left:calc(calc(var(--spacing)*14)*calc(1 - var(--tw-space-x-reverse)))}.gap-y-0\.5{row-gap:calc(var(--spacing)*.5)}.gap-y-1{row-gap:calc(var(--spacing)*1)}.gap-y-2{row-gap:calc(var(--spacing)*2)}.gap-y-3{row-gap:calc(var(--spacing)*3)}.gap-y-4{row-gap:calc(var(--spacing)*4)}.gap-y-5{row-gap:calc(var(--spacing)*5)}.gap-y-6{row-gap:calc(var(--spacing)*6)}.gap-y-7{row-gap:calc(var(--spacing)*7)}.gap-y-8{row-gap:calc(var(--spacing)*8)}.gap-y-10{row-gap:calc(var(--spacing)*10)}.gap-y-11{row-gap:calc(var(--spacing)*11)}.gap-y-\[var\(--input-tag-spacing\)\]{row-gap:var(--input-tag-spacing)}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style)}:where(.divide-x>:not(:last-child)):dir(ltr){border-left-width:calc(1px*var(--tw-divide-x-reverse));border-right-width:calc(1px*calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-x>:not(:last-child)):dir(rtl){border-right-width:calc(1px*var(--tw-divide-x-reverse));border-left-width:calc(1px*calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-\[0\.5px\]>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(.5px*var(--tw-divide-y-reverse));border-bottom-width:calc(.5px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-\[1px\]>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-black\/5>:not(:last-child)){border-color:#0000000d;border-color:lab(0% 0 0/.05)}:where(.divide-black\/15>:not(:last-child)){border-color:#00000026;border-color:lab(0% 0 0/.15)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--gray-200)}:where(.divide-token-bg-tertiary>:not(:last-child)){border-color:var(--bg-tertiary)}:where(.divide-token-border-default>:not(:last-child)){border-color:var(--border-default)}:where(.divide-token-border-light>:not(:last-child)),:where(.divide-token-border-light\/60>:not(:last-child)){border-color:var(--border-light)}@supports (color:color-mix(in lab, red, red)){:where(.divide-token-border-light\/60>:not(:last-child)){border-color:color-mix(in oklab,var(--border-light)60%,transparent)}}:where(.divide-token-border-light\/80>:not(:last-child)){border-color:var(--border-light)}@supports (color:color-mix(in lab, red, red)){:where(.divide-token-border-light\/80>:not(:last-child)){border-color:color-mix(in oklab,var(--border-light)80%,transparent)}}:where(.divide-white\/10>:not(:last-child)){border-color:#ffffff1a;border-color:lab(100% -.0000298023 .0000119209/.1)}.place-self-center{place-self:center}.self-center{align-self:center}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.self-stretch{align-self:stretch}.justify-self-center{justify-self:center}.justify-self-end{justify-self:flex-end}.justify-self-start{justify-self:flex-start}.justify-self-start\!{justify-self:flex-start!important}.justify-self-stretch{justify-self:stretch}.\!truncate{text-overflow:ellipsis!important;white-space:nowrap!important;overflow:hidden!important}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\!overflow-hidden{overflow:hidden!important}.overflow-auto{overflow:auto}.overflow-clip{overflow:clip}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-visible{overflow:visible}.overflow-visible\!{overflow:visible!important}.overflow-x-auto{overflow-x:auto}.overflow-x-clip{overflow-x:clip}.overflow-x-hidden{overflow-x:hidden}.overflow-x-scroll{overflow-x:scroll}.overflow-x-visible{overflow-x:visible}.overflow-y-auto{overflow-y:auto}.overflow-y-clip{overflow-y:clip}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.overflow-y-scroll\!{overflow-y:scroll!important}.overflow-y-visible{overflow-y:visible}.overscroll-contain{overscroll-behavior:contain}.overscroll-x-contain{overscroll-behavior-x:contain}.overscroll-x-none{overscroll-behavior-x:none}.overscroll-y-contain{overscroll-behavior-y:contain}.scroll-smooth{scroll-behavior:smooth}.\!rounded-full{border-radius:3.40282e38px!important}.\!rounded-none{border-radius:0!important}.rounded{border-radius:.25rem}.rounded-\(--sheet-radius\,var\(--sheet-radius-amount\)\){border-radius:var(--sheet-radius,var(--sheet-radius-amount))}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-2xl\!{border-radius:var(--radius-2xl)!important}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-3xl\!{border-radius:var(--radius-3xl)!important}.rounded-4xl{border-radius:var(--radius-4xl)}.rounded-4xl\!{border-radius:var(--radius-4xl)!important}.rounded-\[0\.25em\]{border-radius:.25em}.rounded-\[0\.25rem\]{border-radius:.25rem}.rounded-\[1\.5rem\]{border-radius:1.5rem}.rounded-\[1\.25rem\]{border-radius:1.25rem}.rounded-\[1px\]{border-radius:1px}.rounded-\[2px\]{border-radius:2px}.rounded-\[3px\]{border-radius:3px}.rounded-\[4\.5px\]{border-radius:4.5px}.rounded-\[4px\]{border-radius:4px}.rounded-\[4px\]\!{border-radius:4px!important}.rounded-\[5px\]{border-radius:5px}.rounded-\[6\.667px\]{border-radius:6.667px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-\[9px\]{border-radius:9px}.rounded-\[10px\]{border-radius:10px}.rounded-\[12px\]{border-radius:12px}.rounded-\[14px\]{border-radius:14px}.rounded-\[16px\]{border-radius:16px}.rounded-\[16px_16px_0_0\]{border-radius:16px 16px 0 0}.rounded-\[18px\]{border-radius:18px}.rounded-\[18px\]\!{border-radius:18px!important}.rounded-\[19px\]{border-radius:19px}.rounded-\[20\.636px\]{border-radius:20.636px}.rounded-\[20px\]{border-radius:20px}.rounded-\[20px\]\!{border-radius:20px!important}.rounded-\[22px\]{border-radius:22px}.rounded-\[24\%\]{border-radius:24%}.rounded-\[24px\]{border-radius:24px}.rounded-\[24px\]\!{border-radius:24px!important}.rounded-\[25px\]{border-radius:25px}.rounded-\[26px\]{border-radius:26px}.rounded-\[27px\]{border-radius:27px}.rounded-\[28px\]{border-radius:28px}.rounded-\[30px\]{border-radius:30px}.rounded-\[32px\]{border-radius:32px}.rounded-\[34px\]{border-radius:34px}.rounded-\[36px\]{border-radius:36px}.rounded-\[38px\]{border-radius:38px}.rounded-\[40px\]{border-radius:40px}.rounded-\[64px\]{border-radius:64px}.rounded-\[100px\]{border-radius:100px}.rounded-\[999px\]{border-radius:999px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-full\!{border-radius:3.40282e38px!important}.rounded-lg{border-radius:var(--radius-lg)}.rounded-lg\!{border-radius:var(--radius-lg)!important}.rounded-md{border-radius:var(--radius-md)}.rounded-md\!{border-radius:var(--radius-md)!important}.rounded-none{border-radius:0}.rounded-none\!{border-radius:0!important}.rounded-sm{border-radius:var(--radius-sm)}.rounded-sm\!{border-radius:var(--radius-sm)!important}.rounded-xl{border-radius:var(--radius-xl)}.rounded-xl\!{border-radius:var(--radius-xl)!important}.rounded-xs{border-radius:var(--radius-xs)}.rounded-s:dir(ltr){border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-s:dir(rtl){border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-s-2xl:dir(ltr){border-top-left-radius:var(--radius-2xl);border-bottom-left-radius:var(--radius-2xl)}.rounded-s-2xl:dir(rtl){border-top-right-radius:var(--radius-2xl);border-bottom-right-radius:var(--radius-2xl)}.rounded-s-full:dir(ltr){border-top-left-radius:3.40282e38px;border-bottom-left-radius:3.40282e38px}.rounded-s-full:dir(rtl){border-top-right-radius:3.40282e38px;border-bottom-right-radius:3.40282e38px}.rounded-s-lg\!:dir(ltr){border-top-left-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-s-lg\!:dir(rtl){border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.rounded-s-none:dir(ltr){border-top-left-radius:0;border-bottom-left-radius:0}.rounded-s-none:dir(rtl){border-top-right-radius:0;border-bottom-right-radius:0}.rounded-s-none\!:dir(ltr){border-top-left-radius:0;border-bottom-left-radius:0}.rounded-s-none\!:dir(rtl){border-top-right-radius:0;border-bottom-right-radius:0}.rounded-s-xl:dir(ltr){border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.rounded-s-xl:dir(rtl){border-top-right-radius:var(--radius-xl);border-bottom-right-radius:var(--radius-xl)}.rounded-ss-2xl:dir(ltr){border-top-left-radius:var(--radius-2xl)}.rounded-ss-2xl:dir(rtl){border-top-right-radius:var(--radius-2xl)}.rounded-ss-3xl:dir(ltr){border-top-left-radius:var(--radius-3xl)}.rounded-ss-3xl:dir(rtl){border-top-right-radius:var(--radius-3xl)}.rounded-ss-md:dir(ltr){border-top-left-radius:var(--radius-md)}.rounded-ss-md:dir(rtl){border-top-right-radius:var(--radius-md)}.rounded-ss-xl:dir(ltr){border-top-left-radius:var(--radius-xl)}.rounded-ss-xl:dir(rtl){border-top-right-radius:var(--radius-xl)}.rounded-e:dir(ltr){border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-e:dir(rtl){border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-e-full:dir(ltr){border-top-right-radius:3.40282e38px;border-bottom-right-radius:3.40282e38px}.rounded-e-full:dir(rtl){border-top-left-radius:3.40282e38px;border-bottom-left-radius:3.40282e38px}.rounded-e-lg:dir(ltr){border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.rounded-e-lg:dir(rtl){border-top-left-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-e-lg\!:dir(ltr){border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.rounded-e-lg\!:dir(rtl){border-top-left-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-e-md:dir(ltr){border-top-right-radius:var(--radius-md);border-bottom-right-radius:var(--radius-md)}.rounded-e-md:dir(rtl){border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-e-none:dir(ltr){border-top-right-radius:0;border-bottom-right-radius:0}.rounded-e-none:dir(rtl){border-top-left-radius:0;border-bottom-left-radius:0}.rounded-e-none\!:dir(ltr){border-top-right-radius:0;border-bottom-right-radius:0}.rounded-e-none\!:dir(rtl){border-top-left-radius:0;border-bottom-left-radius:0}.rounded-e-xl:dir(ltr){border-top-right-radius:var(--radius-xl);border-bottom-right-radius:var(--radius-xl)}.rounded-e-xl:dir(rtl){border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.rounded-se-2xl:dir(ltr){border-top-right-radius:var(--radius-2xl)}.rounded-se-2xl:dir(rtl){border-top-left-radius:var(--radius-2xl)}.rounded-se-3xl:dir(ltr){border-top-right-radius:var(--radius-3xl)}.rounded-se-3xl:dir(rtl){border-top-left-radius:var(--radius-3xl)}.rounded-se-full:dir(ltr){border-top-right-radius:3.40282e38px}.rounded-se-full:dir(rtl){border-top-left-radius:3.40282e38px}.rounded-se-lg:dir(ltr){border-top-right-radius:var(--radius-lg)}.rounded-se-lg:dir(rtl){border-top-left-radius:var(--radius-lg)}.rounded-se-md:dir(ltr){border-top-right-radius:var(--radius-md)}.rounded-se-md:dir(rtl){border-top-left-radius:var(--radius-md)}.rounded-se-xl:dir(ltr){border-top-right-radius:var(--radius-xl)}.rounded-se-xl:dir(rtl){border-top-left-radius:var(--radius-xl)}.rounded-ee-2xl:dir(ltr){border-bottom-right-radius:var(--radius-2xl)}.rounded-ee-2xl:dir(rtl){border-bottom-left-radius:var(--radius-2xl)}.rounded-ee-\[50\%\]:dir(ltr){border-bottom-right-radius:50%}.rounded-ee-\[50\%\]:dir(rtl){border-bottom-left-radius:50%}.rounded-ee-full:dir(ltr){border-bottom-right-radius:3.40282e38px}.rounded-ee-full:dir(rtl){border-bottom-left-radius:3.40282e38px}.rounded-ee-md:dir(ltr){border-bottom-right-radius:var(--radius-md)}.rounded-ee-md:dir(rtl){border-bottom-left-radius:var(--radius-md)}.rounded-ee-sm:dir(ltr){border-bottom-right-radius:var(--radius-sm)}.rounded-ee-sm:dir(rtl){border-bottom-left-radius:var(--radius-sm)}.rounded-ee-xl:dir(ltr){border-bottom-right-radius:var(--radius-xl)}.rounded-ee-xl:dir(rtl){border-bottom-left-radius:var(--radius-xl)}.rounded-es-2xl:dir(ltr){border-bottom-left-radius:var(--radius-2xl)}.rounded-es-2xl:dir(rtl){border-bottom-right-radius:var(--radius-2xl)}.rounded-es-\[50\%\]:dir(ltr){border-bottom-left-radius:50%}.rounded-es-\[50\%\]:dir(rtl){border-bottom-right-radius:50%}.rounded-es-md:dir(ltr){border-bottom-left-radius:var(--radius-md)}.rounded-es-md:dir(rtl){border-bottom-right-radius:var(--radius-md)}.rounded-es-xl:dir(ltr){border-bottom-left-radius:var(--radius-xl)}.rounded-es-xl:dir(rtl){border-bottom-right-radius:var(--radius-xl)}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-t-\(--sheet-radius-amount\){border-top-left-radius:var(--sheet-radius-amount);border-top-right-radius:var(--sheet-radius-amount)}.rounded-t-2xl{border-top-left-radius:var(--radius-2xl);border-top-right-radius:var(--radius-2xl)}.rounded-t-3xl{border-top-left-radius:var(--radius-3xl);border-top-right-radius:var(--radius-3xl)}.rounded-t-\[4px\]{border-top-left-radius:4px;border-top-right-radius:4px}.rounded-t-\[5px\]{border-top-left-radius:5px;border-top-right-radius:5px}.rounded-t-\[16px\]{border-top-left-radius:16px;border-top-right-radius:16px}.rounded-t-\[24px\]{border-top-left-radius:24px;border-top-right-radius:24px}.rounded-t-\[28px\]{border-top-left-radius:28px;border-top-right-radius:28px}.rounded-t-\[32px\]{border-top-left-radius:32px;border-top-right-radius:32px}.rounded-t-\[36px\]{border-top-left-radius:36px;border-top-right-radius:36px}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-md{border-top-left-radius:var(--radius-md);border-top-right-radius:var(--radius-md)}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-tl-\[13px\]{border-top-left-radius:13px}.rounded-r-full{border-top-right-radius:3.40282e38px;border-bottom-right-radius:3.40282e38px}.rounded-r-lg{border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.rounded-r-md{border-top-right-radius:var(--radius-md);border-bottom-right-radius:var(--radius-md)}.rounded-tr-\[13px\]{border-top-right-radius:13px}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-b-2xl{border-bottom-right-radius:var(--radius-2xl);border-bottom-left-radius:var(--radius-2xl)}.rounded-b-3xl{border-bottom-right-radius:var(--radius-3xl);border-bottom-left-radius:var(--radius-3xl)}.rounded-b-full{border-bottom-right-radius:3.40282e38px;border-bottom-left-radius:3.40282e38px}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-b-md{border-bottom-right-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-b-none{border-bottom-right-radius:0;border-bottom-left-radius:0}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.rounded-br-\[13px\]{border-bottom-right-radius:13px}.rounded-bl-\[13px\]{border-bottom-left-radius:13px}.btn-danger-outline{border-style:var(--tw-border-style);border-width:1px;border-color:var(--red-600);background-color:var(--main-surface-primary);color:var(--red-600)}@media (hover:hover){.btn-danger-outline:not(:disabled):not([data-disabled]):hover{background-color:var(--main-surface-secondary)}}.btn-secondary{border-style:var(--tw-border-style);border-width:1px;border-color:var(--border-medium);background-color:var(--main-surface-primary);color:var(--text-primary)}@media (hover:hover){.btn-secondary:not(:disabled):not([data-disabled]):hover{background-color:var(--main-surface-secondary)}}.border-thin{border-style:var(--tw-border-style);border-width:1px}@media (-webkit-min-device-pixel-ratio:1.5),(min-resolution:1.5dppx){.border-thin{border-style:var(--tw-border-style);border-width:.5px}}.\!border{border-style:var(--tw-border-style)!important;border-width:1px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border\!{border-style:var(--tw-border-style)!important;border-width:1px!important}.border-0{border-style:var(--tw-border-style);border-width:0}.border-0\!{border-style:var(--tw-border-style)!important;border-width:0!important}.border-1{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-\[\.5px\],.border-\[0\.5px\]{border-style:var(--tw-border-style);border-width:.5px}.border-\[0\.96px\]{border-style:var(--tw-border-style);border-width:.96px}.border-\[1px\]{border-style:var(--tw-border-style);border-width:1px}.border-\[2\.5px\]{border-style:var(--tw-border-style);border-width:2.5px}.border-\[3px\]{border-style:var(--tw-border-style);border-width:3px}.border-\[4px\]{border-style:var(--tw-border-style);border-width:4px}.border-x{border-inline-style:var(--tw-border-style);border-left-width:1px;border-right-width:1px}.border-x-0{border-inline-style:var(--tw-border-style);border-left-width:0;border-right-width:0}.\[border-inline-width\:1px\]{border-left-width:1px;border-right-width:1px}.border-y{border-block-style:var(--tw-border-style);border-top-width:1px;border-bottom-width:1px}.border-y-0{border-block-style:var(--tw-border-style);border-top-width:0;border-bottom-width:0}.border-s-thin:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:1px}.border-s-thin:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:1px}@media (-webkit-min-device-pixel-ratio:1.5),(min-resolution:1.5dppx){.border-s-thin:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:.5px}.border-s-thin:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:.5px}}.border-s:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:1px}.border-s:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:1px}.border-s-0:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:0}.border-s-0:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:0}.border-s-0\!:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:0}.border-s-0\!:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:0}.border-s-2:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:2px}.border-s-2:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:2px}.border-s-3:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:3px}.border-s-3:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:3px}.border-s-4:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:4px}.border-s-4:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:4px}.border-s-\[0\.5px\]:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:.5px}.border-s-\[0\.5px\]:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:.5px}.border-e:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:1px}.border-e:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:1px}.border-e-0:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:0}.border-e-0:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:0}.border-e-0\!:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:0}.border-e-0\!:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:0}.border-e-2:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:2px}.border-e-2:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:2px}.border-e-4:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:4px}.border-e-4:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:4px}.border-e-\[1px\]:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:1px}.border-e-\[1px\]:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t\!{border-top-style:var(--tw-border-style)!important;border-top-width:1px!important}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-t-0\!{border-top-style:var(--tw-border-style)!important;border-top-width:0!important}.border-t-1{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-t-3{border-top-style:var(--tw-border-style);border-top-width:3px}.border-t-4{border-top-style:var(--tw-border-style);border-top-width:4px}.border-t-\[0\.5px\]{border-top-style:var(--tw-border-style);border-top-width:.5px}.border-t-\[1px\]{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-\[3px\]{border-top-style:var(--tw-border-style);border-top-width:3px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.sheet-border{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-bottom-color:var(--border-light)}.border-b-thin{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}@media (-webkit-min-device-pixel-ratio:1.5),(min-resolution:1.5dppx){.border-b-thin{border-bottom-style:var(--tw-border-style);border-bottom-width:.5px}}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.border-b-1{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-b-\[0\.5px\]{border-bottom-style:var(--tw-border-style);border-bottom-width:.5px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.\!border-none{--tw-border-style:none!important;border-style:none!important}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-dotted{--tw-border-style:dotted;border-style:dotted}.border-double{--tw-border-style:double;border-style:double}.border-none{--tw-border-style:none;border-style:none}.border-none\!{--tw-border-style:none!important;border-style:none!important}.border-solid{--tw-border-style:solid;border-style:solid}.interactive-border-secondary{border-color:var(--interactive-border-default-secondary)}@media (hover:hover){.interactive-border-secondary:hover{border-color:var(--interactive-border-hover-secondary)}}.interactive-border-secondary:focus-visible{border-color:var(--interactive-border-focus)}.interactive-border-secondary:disabled,.interactive-border-secondary:where([data-visually-disabled]){border-color:var(--interactive-border-inactive-secondary)}.interactive-border-secondary:checked{border-color:var(--interactive-border-selected-secondary)}.interactive-border-secondary:active{border-color:var(--interactive-border-press-secondary)}.\!border-\[rgba\(13\,13\,13\,0\.15\)\]{border-color:#0d0d0d26!important}.\!border-gray-200{border-color:var(--gray-200)!important}.border-\(--bento-border-color\){border-color:var(--bento-border-color)}.border-\[\#0D0D0D1A\]{border-color:#0d0d0d1a}.border-\[\#0FA968\]\/30{border-color:#0fa9684d;border-color:lab(61.1228% -49.5053 22.9273/.3)}.border-\[\#0c8b6b\]\/40{border-color:#0c8b6b66;border-color:lab(51.2463% -38.7928 7.89099/.4)}.border-\[\#0c8b6b\]\/60{border-color:#0c8b6b99;border-color:lab(51.2463% -38.7928 7.89099/.6)}.border-\[\#007aff29\]{border-color:#007aff29}.border-\[\#0285FF\]{border-color:#0285ff}.border-\[\#0285FF\]\/30{border-color:#0285ff4d;border-color:lab(54.959% 5.86918 -70.2582/.3)}.border-\[\#5856D6\]{border-color:#5856d6}.border-\[\#7496FD\]{border-color:#7496fd}.border-\[\#AF52DE\]{border-color:#af52de}.border-\[\#B3DBFF\]{border-color:#b3dbff}.border-\[\#C5CEFF\]{border-color:#c5ceff}.border-\[\#CFCEFC\]{border-color:#cfcefc}.border-\[\#D0D5DD\]\!{border-color:#d0d5dd!important}.border-\[\#D9D7F8\]\!{border-color:#d9d7f8!important}.border-\[\#E25507\]{border-color:#e25507}.border-\[\#df1b41\]{border-color:#df1b41}.border-\[\#e3e6e8\]{border-color:#e3e6e8}.border-\[\#e7c85f\]{border-color:#e7c85f}.border-\[\#f0d2a9\]{border-color:#f0d2a9}.border-\[\#f4f4f4\]{border-color:#f4f4f4}.border-\[\#f7f7f7\]{border-color:#f7f7f7}.border-\[color-mix\(in_srgb\,var\(--border-default\)_78\%\,black\)\]{border-color:var(--border-default)}@supports (color:color-mix(in lab, red, red)){.border-\[color-mix\(in_srgb\,var\(--border-default\)_78\%\,black\)\]{border-color:color-mix(in srgb,var(--border-default)78%,black)}}.border-\[color\:var\(--border-light\,rgba\(0\,0\,0\,0\.10\)\)\]{border-color:var(--border-light,#0000001a)}.border-\[rgba\(0\,0\,0\,0\.10\)\]{border-color:#0000001a}.border-\[rgba\(0\,0\,0\,0\.18\)\]{border-color:#0000002e}.border-\[rgba\(13\,13\,13\,0\.05\)\]{border-color:#0d0d0d0d}.border-\[rgba\(13\,163\,110\,0\.16\)\]{border-color:#0da36e29}.border-\[rgba\(47\,124\,245\,0\.2\)\]{border-color:#2f7cf533}.border-\[rgba\(97\,87\,255\,0\.25\)\]{border-color:#6157ff40}.border-\[rgba\(185\,200\,246\,0\.6\)\]{border-color:#b9c8f699}.border-\[var\(--border-default\)\]{border-color:var(--border-default)}.border-\[var\(--border-heavy\)\]{border-color:var(--border-heavy)}.border-\[var\(--border-light\)\]{border-color:var(--border-light)}.border-\[var\(--border-subtle\)\]{border-color:var(--border-subtle)}.border-\[var\(--theme-user-msg-text\)\]{border-color:var(--theme-user-msg-text)}.border-black{border-color:#000}.border-black\!{border-color:#000!important}.border-black\/5{border-color:#0000000d;border-color:lab(0% 0 0/.05)}.border-black\/10{border-color:#0000001a;border-color:lab(0% 0 0/.1)}.border-black\/20{border-color:#0003;border-color:lab(0% 0 0/.2)}.border-black\/25{border-color:#00000040;border-color:lab(0% 0 0/.25)}.border-black\/\[0\.03\]{border-color:#00000008;border-color:lab(0% 0 0/.03)}.border-black\/\[0\.12\]{border-color:#0000001f;border-color:lab(0% 0 0/.12)}.border-black\/\[0\.075\]{border-color:#00000013;border-color:lab(0% 0 0/.075)}.border-blue-100{border-color:var(--blue-100)}.border-blue-200{border-color:var(--blue-200)}.border-blue-300{border-color:var(--blue-300)}.border-blue-400{border-color:var(--blue-400)}.border-blue-400\!{border-color:var(--blue-400)!important}.border-blue-400\/\[\.3\]{border-color:var(--blue-400)}@supports (color:color-mix(in lab, red, red)){.border-blue-400\/\[\.3\]{border-color:color-mix(in oklab,var(--blue-400)30%,transparent)}}.border-blue-500,.border-blue-500\/20{border-color:var(--blue-500)}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/20{border-color:color-mix(in oklab,var(--blue-500)20%,transparent)}}.border-blue-500\/40{border-color:var(--blue-500)}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/40{border-color:color-mix(in oklab,var(--blue-500)40%,transparent)}}.border-blue-600{border-color:var(--blue-600)}.border-brand-green-800{border-color:#05a746}.border-brand-purple{border-color:#ab68ff}.border-current,.border-current\/30{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/30{border-color:color-mix(in oklab,currentcolor 30%,transparent)}}.border-gray-100{border-color:var(--gray-100)}.border-gray-200{border-color:var(--gray-200)}.border-gray-300{border-color:var(--gray-300)}.border-gray-400{border-color:var(--gray-400)}.border-gray-500{border-color:var(--gray-500)}.border-gray-700{border-color:var(--gray-700)}.border-gray-800{border-color:var(--gray-800)}.border-green-200{border-color:var(--green-200)}.border-green-300\/70{border-color:var(--green-300)}@supports (color:color-mix(in lab, red, red)){.border-green-300\/70{border-color:color-mix(in oklab,var(--green-300)70%,transparent)}}.border-green-500,.border-green-500\/30{border-color:var(--green-500)}@supports (color:color-mix(in lab, red, red)){.border-green-500\/30{border-color:color-mix(in oklab,var(--green-500)30%,transparent)}}.border-green-600{border-color:var(--green-600)}.border-orange-50{border-color:var(--orange-50)}.border-orange-400,.border-orange-400\/15{border-color:var(--orange-400)}@supports (color:color-mix(in lab, red, red)){.border-orange-400\/15{border-color:color-mix(in oklab,var(--orange-400)15%,transparent)}}.border-orange-500,.border-orange-500\/30{border-color:var(--orange-500)}@supports (color:color-mix(in lab, red, red)){.border-orange-500\/30{border-color:color-mix(in oklab,var(--orange-500)30%,transparent)}}.border-red-200{border-color:var(--red-200)}.border-red-300,.border-red-300\/70{border-color:var(--red-300)}@supports (color:color-mix(in lab, red, red)){.border-red-300\/70{border-color:color-mix(in oklab,var(--red-300)70%,transparent)}}.border-red-400,.border-red-400\/40{border-color:var(--red-400)}@supports (color:color-mix(in lab, red, red)){.border-red-400\/40{border-color:color-mix(in oklab,var(--red-400)40%,transparent)}}.border-red-500{border-color:var(--red-500)}.border-red-500\!{border-color:var(--red-500)!important}.border-red-500\/30{border-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.border-red-500\/30{border-color:color-mix(in oklab,var(--red-500)30%,transparent)}}.border-red-500\/40{border-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.border-red-500\/40{border-color:color-mix(in oklab,var(--red-500)40%,transparent)}}.border-red-500\/50{border-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.border-red-500\/50{border-color:color-mix(in oklab,var(--red-500)50%,transparent)}}.border-red-600{border-color:var(--red-600)}.border-red-700{border-color:var(--red-700)}.border-token-bg-primary{border-color:var(--bg-primary)}.border-token-bg-secondary{border-color:var(--bg-secondary)}.border-token-bg-tertiary{border-color:var(--bg-tertiary)}.border-token-border-default{border-color:var(--border-default)}.border-token-border-default\!{border-color:var(--border-default)!important}.border-token-border-default\/40{border-color:var(--border-default)}@supports (color:color-mix(in lab, red, red)){.border-token-border-default\/40{border-color:color-mix(in oklab,var(--border-default)40%,transparent)}}.border-token-border-default\/60{border-color:var(--border-default)}@supports (color:color-mix(in lab, red, red)){.border-token-border-default\/60{border-color:color-mix(in oklab,var(--border-default)60%,transparent)}}.border-token-border-default\/70{border-color:var(--border-default)}@supports (color:color-mix(in lab, red, red)){.border-token-border-default\/70{border-color:color-mix(in oklab,var(--border-default)70%,transparent)}}.border-token-border-default\/80{border-color:var(--border-default)}@supports (color:color-mix(in lab, red, red)){.border-token-border-default\/80{border-color:color-mix(in oklab,var(--border-default)80%,transparent)}}.border-token-border-heavy{border-color:var(--border-heavy)}.border-token-border-heavy\!{border-color:var(--border-heavy)!important}.border-token-border-light{border-color:var(--border-light)}.border-token-border-light\!{border-color:var(--border-light)!important}.border-token-border-light\/30{border-color:var(--border-light)}@supports (color:color-mix(in lab, red, red)){.border-token-border-light\/30{border-color:color-mix(in oklab,var(--border-light)30%,transparent)}}.border-token-border-light\/40{border-color:var(--border-light)}@supports (color:color-mix(in lab, red, red)){.border-token-border-light\/40{border-color:color-mix(in oklab,var(--border-light)40%,transparent)}}.border-token-border-light\/60{border-color:var(--border-light)}@supports (color:color-mix(in lab, red, red)){.border-token-border-light\/60{border-color:color-mix(in oklab,var(--border-light)60%,transparent)}}.border-token-border-light\/70{border-color:var(--border-light)}@supports (color:color-mix(in lab, red, red)){.border-token-border-light\/70{border-color:color-mix(in oklab,var(--border-light)70%,transparent)}}.border-token-border-light\/80{border-color:var(--border-light)}@supports (color:color-mix(in lab, red, red)){.border-token-border-light\/80{border-color:color-mix(in oklab,var(--border-light)80%,transparent)}}.border-token-border-medium{border-color:var(--border-medium)}.border-token-border-medium\!{border-color:var(--border-medium)!important}.border-token-border-sharp{border-color:var(--border-sharp)}.border-token-border-status-error{border-color:var(--border-status-error)}.border-token-border-status-warning{border-color:var(--border-status-warning)}.border-token-border-xheavy{border-color:var(--border-xheavy)}.border-token-border-xlight{border-color:var(--border-xlight)}.border-token-border-xlight\!{border-color:var(--border-xlight)!important}.border-token-interactive-border-danger-secondary-default{border-color:var(--interactive-border-danger-secondary-default)}.border-token-interactive-border-secondary-default{border-color:var(--interactive-border-secondary-default)}.border-token-interactive-border-tertiary-default{border-color:var(--interactive-border-tertiary-default)}.border-token-interactive-label-danger-secondary-default{border-color:var(--interactive-label-danger-secondary-default)}.border-token-main-surface-primary{border-color:var(--main-surface-primary)}.border-token-main-surface-secondary{border-color:var(--main-surface-secondary)}.border-token-main-surface-tertiary{border-color:var(--main-surface-tertiary)}.border-token-surface-error\/5{border-color:rgb(var(--surface-error)/1)}@supports (color:color-mix(in lab, red, red)){.border-token-surface-error\/5{border-color:color-mix(in oklab,rgb(var(--surface-error)/1)5%,transparent)}}.border-token-surface-error\/15{border-color:rgb(var(--surface-error)/1)}@supports (color:color-mix(in lab, red, red)){.border-token-surface-error\/15{border-color:color-mix(in oklab,rgb(var(--surface-error)/1)15%,transparent)}}.border-token-text-accent{border-color:var(--text-accent)}.border-token-text-error{border-color:var(--text-error)}.border-token-text-primary{border-color:var(--text-primary)}.border-token-text-primary\!{border-color:var(--text-primary)!important}.border-token-text-primary\/12{border-color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.border-token-text-primary\/12{border-color:color-mix(in oklab,var(--text-primary)12%,transparent)}}.border-token-text-primary\/44{border-color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.border-token-text-primary\/44{border-color:color-mix(in oklab,var(--text-primary)44%,transparent)}}.border-token-text-quaternary{border-color:var(--text-quaternary)}.border-token-text-secondary{border-color:var(--text-secondary)}.border-token-text-status-error{border-color:var(--text-status-error)}.border-token-text-tertiary{border-color:var(--text-tertiary)}.border-transparent{border-color:#0000}.border-transparent\!{border-color:#0000!important}.border-white{border-color:#fff}.border-white\!{border-color:#fff!important}.border-white\/5{border-color:#ffffff0d;border-color:lab(100% -.0000298023 .0000119209/.05)}.border-white\/10{border-color:#ffffff1a;border-color:lab(100% -.0000298023 .0000119209/.1)}.border-white\/10\!{border-color:#ffffff1a!important;border-color:lab(100% -.0000298023 .0000119209/.1)!important}.border-white\/15{border-color:#ffffff26;border-color:lab(100% -.0000298023 .0000119209/.15)}.border-white\/20{border-color:#fff3;border-color:lab(100% -.0000298023 .0000119209/.2)}.border-white\/25{border-color:#ffffff40;border-color:lab(100% -.0000298023 .0000119209/.25)}.border-white\/30{border-color:#ffffff4d;border-color:lab(100% -.0000298023 .0000119209/.3)}.border-white\/35{border-color:#ffffff59;border-color:lab(100% -.0000298023 .0000119209/.35)}.border-white\/40{border-color:#fff6;border-color:lab(100% -.0000298023 .0000119209/.4)}.border-white\/50{border-color:#ffffff80;border-color:lab(100% -.0000298023 .0000119209/.5)}.border-white\/60{border-color:#fff9;border-color:lab(100% -.0000298023 .0000119209/.6)}.border-white\/80{border-color:#fffc;border-color:lab(100% -.0000298023 .0000119209/.8)}.border-yellow-200{border-color:var(--yellow-200)}.border-yellow-300\/80{border-color:var(--yellow-300)}@supports (color:color-mix(in lab, red, red)){.border-yellow-300\/80{border-color:color-mix(in oklab,var(--yellow-300)80%,transparent)}}.border-yellow-500\/30{border-color:var(--yellow-500)}@supports (color:color-mix(in lab, red, red)){.border-yellow-500\/30{border-color:color-mix(in oklab,var(--yellow-500)30%,transparent)}}.border-y-token-border-heavy{border-block-color:var(--border-heavy)}.border-s-token-border-sharp:dir(ltr){border-left-color:var(--border-sharp)}.border-s-token-border-sharp:dir(rtl){border-right-color:var(--border-sharp)}.border-s-token-sidebar-surface-secondary:dir(ltr){border-left-color:var(--sidebar-surface-secondary)}.border-s-token-sidebar-surface-secondary:dir(rtl){border-right-color:var(--sidebar-surface-secondary)}.border-s-transparent:dir(ltr){border-left-color:#0000}.border-s-transparent:dir(rtl){border-right-color:#0000}.border-e-transparent:dir(ltr){border-right-color:#0000}.border-e-transparent:dir(rtl){border-left-color:#0000}.border-t-black\/\[0\.075\]{border-top-color:#00000013;border-top-color:lab(0% 0 0/.075)}.border-t-current{border-top-color:currentColor}.border-t-token-border-default{border-top-color:var(--border-default)}.border-t-token-border-xlight{border-top-color:var(--border-xlight)}.border-t-token-text-primary{border-top-color:var(--text-primary)}.border-t-transparent{border-top-color:#0000}.border-t-white{border-top-color:#fff}.border-b-\[rgba\(0\,0\,0\,0\.10\)\]{border-bottom-color:#0000001a}.border-b-black{border-bottom-color:#000}.border-b-token-bg-secondary{border-bottom-color:var(--bg-secondary)}.border-b-token-border-default{border-bottom-color:var(--border-default)}.border-b-transparent{border-bottom-color:#0000}.btn-primary{background-color:var(--gray-950);color:#fff;background-clip:padding-box}.btn-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-50);color:var(--gray-950)}@media (hover:hover){.btn-primary:not(:disabled):not([data-disabled]):hover{background-color:var(--gray-800)}.btn-primary:not(:disabled):not([data-disabled]):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--gray-100)}}.btn-primary[data-visually-disabled]{background-color:var(--gray-950)}@supports (color:color-mix(in lab, red, red)){.btn-primary[data-visually-disabled]{background-color:color-mix(in oklab,var(--gray-950)50%,transparent)}}.btn-primary[data-visually-disabled]{color:var(--text-inverted-static)}.btn-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))[data-visually-disabled]{background-color:var(--gray-50)}@supports (color:color-mix(in lab, red, red)){.btn-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))[data-visually-disabled]{background-color:color-mix(in oklab,var(--gray-50)50%,transparent)}}.btn-accent{background-color:var(--bg-accent-static);color:#fff;background-clip:padding-box}@media (hover:hover){.btn-accent:not(:disabled):not([data-disabled]):hover{background-color:var(--bg-accent-static)}@supports (color:color-mix(in lab, red, red)){.btn-accent:not(:disabled):not([data-disabled]):hover{background-color:color-mix(in oklab,var(--bg-accent-static)90%,transparent)}}}.btn-blue{color:#fff;background-color:#0066de;background-clip:padding-box}@media (hover:hover){.btn-blue:not(:disabled):not([data-disabled]):hover{background-color:var(--blue-700)}}.btn-custom{background-color:unset;color:unset;background-clip:padding-box}@media (hover:hover){.btn-custom:not(:disabled):not([data-disabled]):hover{background-color:unset}}.btn-danger{background-color:var(--red-500);color:#fff;background-clip:padding-box}@media (hover:hover){.btn-danger:not(:disabled):not([data-disabled]):hover{background-color:var(--red-700)}}.btn-green{background-color:var(--green-600);color:#fff;background-clip:padding-box}@media (hover:hover){.btn-green:not(:disabled):not([data-disabled]):hover{background-color:var(--green-700)}}.btn-purple{color:#fff;background-color:#615eeb;background-clip:padding-box}@media (hover:hover){.btn-purple:not(:disabled):not([data-disabled]):hover{background-color:#6353c3}}.btn-primary-inverse{background-color:var(--gray-50);color:var(--gray-950)}.btn-primary-inverse:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-950);color:#fff}@media (hover:hover){.btn-primary-inverse:not(:disabled):not([data-disabled]):hover{background-color:var(--gray-100)}.btn-primary-inverse:not(:disabled):not([data-disabled]):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--gray-800)}}.composer-secondary-button-color{background-color:var(--theme-secondary-btn-bg);color:var(--theme-secondary-btn-text)}.composer-submit-button-color{background-color:var(--theme-submit-btn-bg);color:var(--theme-submit-btn-text)}.user-message-bubble-color{background-color:var(--theme-user-msg-bg);color:var(--theme-user-msg-text)}.interactive-bg-accent{background-color:var(--interactive-bg-default-accent)}@media (hover:hover){.interactive-bg-accent:hover{background-color:var(--interactive-bg-hover-accent)}}.interactive-bg-accent:focus-visible{background-color:var(--interactive-bg-hover-accent)}.interactive-bg-accent:disabled,.interactive-bg-accent:where([data-visually-disabled]){background-color:var(--interactive-bg-inactive-accent)}.interactive-bg-accent:checked{background-color:var(--interactive-bg-selected-accent)}.interactive-bg-accent:active{background-color:var(--interactive-bg-press-accent)}.interactive-bg-danger-primary{background-color:var(--interactive-bg-default-danger-primary)}@media (hover:hover){.interactive-bg-danger-primary:hover{background-color:var(--interactive-bg-hover-danger-primary)}}.interactive-bg-danger-primary:focus-visible{background-color:var(--interactive-bg-hover-danger-primary)}.interactive-bg-danger-primary:disabled,.interactive-bg-danger-primary:where([data-visually-disabled]){background-color:var(--interactive-bg-inactive-danger-primary)}.interactive-bg-danger-primary:checked{background-color:var(--interactive-bg-selected-danger-primary)}.interactive-bg-danger-primary:active{background-color:var(--interactive-bg-press-danger-primary)}.interactive-bg-primary{background-color:var(--interactive-bg-default-primary)}@media (hover:hover){.interactive-bg-primary:hover{background-color:var(--interactive-bg-hover-primary)}}.interactive-bg-primary:focus-visible{background-color:var(--interactive-bg-hover-primary)}.interactive-bg-primary:disabled,.interactive-bg-primary:where([data-visually-disabled]){background-color:var(--interactive-bg-inactive-primary)}.interactive-bg-primary:checked{background-color:var(--interactive-bg-selected-primary)}.interactive-bg-primary:active{background-color:var(--interactive-bg-press-primary)}.interactive-bg-secondary{background-color:var(--interactive-bg-default-secondary)}@media (hover:hover){.interactive-bg-secondary:hover{background-color:var(--interactive-bg-hover-secondary)}}.interactive-bg-secondary:focus-visible{background-color:var(--interactive-bg-hover-secondary)}.interactive-bg-secondary:disabled,.interactive-bg-secondary:where([data-visually-disabled]){background-color:var(--interactive-bg-inactive-secondary)}.interactive-bg-secondary:checked{background-color:var(--interactive-bg-selected-secondary)}.interactive-bg-secondary:active{background-color:var(--interactive-bg-press-secondary)}@media (hover:hover){.btn-ghost:not(:disabled):not([data-disabled]):hover{background-color:#0000000d;background-color:lab(0% 0 0/.05)}.btn-ghost:not(:disabled):not([data-disabled]):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#ffffff1a;background-color:lab(100% -.0000298023 .0000119209/.1)}}.\!bg-\[\#0D0D0D\]{background-color:#0d0d0d!important}.\!bg-\[\#F3F3F3\]{background-color:#f3f3f3!important}.\!bg-token-bg-primary{background-color:var(--bg-primary)!important}.\!bg-token-bg-tertiary{background-color:var(--bg-tertiary)!important}.\!bg-white{background-color:#fff!important}.attribution-highlight-bg{background-color:var(--theme-attribution-highlight-bg)}.bg-\(--bg-quaternary\){background-color:var(--bg-quaternary)}.bg-\(--sidebar-bg\,var\(--bg-elevated-secondary\)\){background-color:var(--sidebar-bg,var(--bg-elevated-secondary))}.bg-\(--sidebar-mask-bg\){background-color:var(--sidebar-mask-bg)}.bg-\(--sidebar-mask-bg\,var\(--bg-elevated-secondary\)\){background-color:var(--sidebar-mask-bg,var(--bg-elevated-secondary))}.bg-\(--sidebar-moweb-bg\,var\(--sidebar-surface-primary\)\){background-color:var(--sidebar-moweb-bg,var(--sidebar-surface-primary))}.bg-\(--theme-submit-btn-bg\){background-color:var(--theme-submit-btn-bg)}.bg-\(--theme-user-msg-text\)\/10{background-color:var(--theme-user-msg-text)}@supports (color:color-mix(in lab, red, red)){.bg-\(--theme-user-msg-text\)\/10{background-color:color-mix(in oklab,var(--theme-user-msg-text)10%,transparent)}}.bg-\[\#0D0D0D66\]{background-color:#0d0d0d66}.bg-\[\#0FA968\]\/10{background-color:#0fa9681a;background-color:lab(61.1228% -49.5052 22.9274/.1)}.bg-\[\#0d0d0d\]{background-color:#0d0d0d}.bg-\[\#0e1630\]{background-color:#0e1630}.bg-\[\#0f0f0f\]{background-color:#0f0f0f}.bg-\[\#0000000f\]{background-color:#0000000f}.bg-\[\#3a3a3a\]{background-color:#3a3a3a}.bg-\[\#04b84c\]{background-color:#04b84c}.bg-\[\#5B8AF0\]{background-color:#5b8af0}.bg-\[\#5B8DEF\]{background-color:#5b8def}.bg-\[\#5f4f37\]{background-color:#5f4f37}.bg-\[\#6B91F1\]{background-color:#6b91f1}.bg-\[\#007afd0d\]{background-color:#007afd0d}.bg-\[\#8C43A00D\]{background-color:#8c43a00d}.bg-\[\#8E3CF320\]{background-color:#8e3cf320}.bg-\[\#8F8F8F\]{background-color:#8f8f8f}.bg-\[\#10A37F\],.bg-\[\#10a37f\]{background-color:#10a37f}.bg-\[\#10a37f\]\/15{background-color:#10a37f26;background-color:lab(59.5786% -43.3964 8.32964/.15)}.bg-\[\#14A27F\]{background-color:#14a27f}.bg-\[\#22c55e\]{background-color:#22c55e}.bg-\[\#34A853\]{background-color:#34a853}.bg-\[\#78C6F0\]{background-color:#78c6f0}.bg-\[\#0088FF\]{background-color:#08f}.bg-\[\#282C34\]{background-color:#282c34}.bg-\[\#0285FF21\]{background-color:#0285ff21}.bg-\[\#0285FF\]{background-color:#0285ff}.bg-\[\#0285FF\]\/10{background-color:#0285ff1a;background-color:lab(54.959% 5.86918 -70.2582/.1)}.bg-\[\#4243DB\]\/40{background-color:#4243db66;background-color:lab(36.9855% 38.5486 -79.2408/.4)}.bg-\[\#4285F4\]{background-color:#4285f4}.bg-\[\#5856D6\]{background-color:#5856d6}.bg-\[\#5856D612\]{background-color:#5856d612}.bg-\[\#7496FD\]{background-color:#7496fd}.bg-\[\#7496FD\]\/20{background-color:#7496fd33;background-color:lab(63.0977% 10.2599 -55.9278/.2)}.bg-\[\#59636E20\]{background-color:#59636e20}.bg-\[\#131314\]{background-color:#131314}.bg-\[\#303030\]{background-color:#303030}.bg-\[\#303030\]\!{background-color:#303030!important}.bg-\[\#AF52DE\]{background-color:#af52de}.bg-\[\#C3DEC780\]{background-color:#c3dec780}.bg-\[\#CEDFFE\]{background-color:#cedffe}.bg-\[\#D6303D20\]{background-color:#d6303d20}.bg-\[\#DCDBFF\]{background-color:#dcdbff}.bg-\[\#DFEFFF\]{background-color:#dfefff}.bg-\[\#E0FFE7\]{background-color:#e0ffe7}.bg-\[\#E5F3FF\]{background-color:#e5f3ff}.bg-\[\#E6F0FF\]{background-color:#e6f0ff}.bg-\[\#E8EBFF\]{background-color:#e8ebff}.bg-\[\#E8F6EC\]{background-color:#e8f6ec}.bg-\[\#ECF0FF\]{background-color:#ecf0ff}.bg-\[\#EDF7FF\]{background-color:#edf7ff}.bg-\[\#F1F1F1\]{background-color:#f1f1f1}.bg-\[\#F1F1FB\]{background-color:#f1f1fb}.bg-\[\#F2FBF4\]{background-color:#f2fbf4}.bg-\[\#F3F3F3\]{background-color:#f3f3f3}.bg-\[\#F3F4F6\]{background-color:#f3f4f6}.bg-\[\#F4F4F4\]{background-color:#f4f4f4}.bg-\[\#F4F4F4\]\!{background-color:#f4f4f4!important}.bg-\[\#F5F4FF\]\!{background-color:#f5f4ff!important}.bg-\[\#F5F5FF\]{background-color:#f5f5ff}.bg-\[\#F7F7F7\]{background-color:#f7f7f7}.bg-\[\#FB6A2229\]{background-color:#fb6a2229}.bg-\[\#FCEBEC\]{background-color:#fcebec}.bg-\[\#FCF2F3\]{background-color:#fcf2f3}.bg-\[\#FCFCFC\]{background-color:#fcfcfc}.bg-\[\#FF3B30\]{background-color:#ff3b30}.bg-\[\#FF6E3D\]{background-color:#ff6e3d}.bg-\[\#FF5488\]{background-color:#ff5488}.bg-\[\#FFFFFF44\]{background-color:#fff4}.bg-\[\#d9f4e4\]{background-color:#d9f4e4}.bg-\[\#e2c541\]{background-color:#e2c541}.bg-\[\#e8e8e8\]{background-color:#e8e8e8}.bg-\[\#ebebf0\]{background-color:#ebebf0}.bg-\[\#f2f4f8\]{background-color:#f2f4f8}.bg-\[\#f4f4f4\]{background-color:#f4f4f4}.bg-\[\#f5f5f5\]{background-color:#f5f5f5}.bg-\[\#f5f5f7\]{background-color:#f5f5f7}.bg-\[\#f6f6f6\]\/40{background-color:#f6f6f666;background-color:lab(96.8849% 0 0/.4)}.bg-\[\#f7f7f7\]{background-color:#f7f7f7}.bg-\[\#f7f7f7\]\!{background-color:#f7f7f7!important}.bg-\[\#f8f8f8\]{background-color:#f8f8f8}.bg-\[\#f8fafd\]{background-color:#f8fafd}.bg-\[\#f87171\]{background-color:#f87171}.bg-\[\#fae271\]{background-color:#fae271}.bg-\[\#fff0f0\]{background-color:#fff0f0}.bg-\[\#fff5f7\]{background-color:#fff5f7}.bg-\[\#fff7e6\]{background-color:#fff7e6}.bg-\[\#fff8eb\]{background-color:#fff8eb}.bg-\[Highlight\]{background-color:highlight}.bg-\[Highlight\]\!{background-color:highlight!important}.bg-\[color-mix\(in_srgb\,\#000_42\%\,transparent\)\]{background-color:#0000006b}.bg-\[color-mix\(in_srgb\,var\(--bg-primary\)_74\%\,black\)\]{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-\[color-mix\(in_srgb\,var\(--bg-primary\)_74\%\,black\)\]{background-color:color-mix(in srgb,var(--bg-primary)74%,black)}}.bg-\[color-mix\(in_srgb\,var\(--main-surface-secondary\)_54\%\,transparent\)\]{background-color:var(--main-surface-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-\[color-mix\(in_srgb\,var\(--main-surface-secondary\)_54\%\,transparent\)\]{background-color:color-mix(in srgb,var(--main-surface-secondary)54%,transparent)}}.bg-\[color-mix\(in_srgb\,var\(--main-surface-secondary\)_84\%\,transparent\)\]{background-color:var(--main-surface-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-\[color-mix\(in_srgb\,var\(--main-surface-secondary\)_84\%\,transparent\)\]{background-color:color-mix(in srgb,var(--main-surface-secondary)84%,transparent)}}.bg-\[rgb\(34_197_94_\/_1\)\]{background-color:#22c55e}.bg-\[rgb\(239_68_68_\/_1\)\]{background-color:#ef4444}.bg-\[rgb\(242_242_242\/0\.72\)\]{background-color:#f2f2f2b8}.bg-\[rgba\(0\,0\,0\,0\.03\)\]{background-color:#00000008}.bg-\[rgba\(0\,0\,0\,0\.04\)\]{background-color:#0000000a}.bg-\[rgba\(0\,0\,0\,0\.05\)\]{background-color:#0000000d}.bg-\[rgba\(0\,0\,0\,0\.5\)\]{background-color:#00000080}.bg-\[rgba\(0\,0\,0\,0\.16\)\]{background-color:#00000029}.bg-\[rgba\(0\,0\,0\,0\.024\)\]{background-color:#00000006}.bg-\[rgba\(0\,0\,0\,0\.34\)\]{background-color:#00000057}.bg-\[rgba\(13\,13\,13\,0\.90\)\]\!{background-color:#0d0d0de6!important}.bg-\[rgba\(13\,163\,110\,0\.08\)\]{background-color:#0da36e14}.bg-\[rgba\(13\,163\,110\,0\.12\)\]{background-color:#0da36e1f}.bg-\[rgba\(29\,155\,209\,0\.1\)\]{background-color:#1d9bd11a}.bg-\[rgba\(47\,124\,245\,0\.08\)\]{background-color:#2f7cf514}.bg-\[rgba\(88\,86\,214\,0\.15\)\]\!{background-color:#5856d626!important}.bg-\[rgba\(88\,86\,214\,0\.25\)\]\!{background-color:#5856d640!important}.bg-\[rgba\(97\,87\,255\,0\.05\)\]{background-color:#6157ff0d}.bg-\[rgba\(122\,92\,255\,0\.04\)\]{background-color:#7a5cff0a}.bg-\[rgba\(244\,240\,239\,0\.5\)\]{background-color:#f4f0ef80}.bg-\[rgba\(245\,245\,245\,1\)\]{background-color:#f5f5f5}.bg-\[rgba\(249\,249\,249\,1\)\]{background-color:#f9f9f9}.bg-\[rgba\(255\,214\,10\,0\.1\)\]{background-color:#ffd60a1a}.bg-\[rgba\(255\,255\,255\,0\.3\)\]{background-color:#ffffff4d}.bg-\[var\(--bg-quaternary\)\]{background-color:var(--bg-quaternary)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--bg-tertiary\)\]{background-color:var(--bg-tertiary)}.bg-\[var\(--bg-tertiary\,\#F3F3F3\)\]{background-color:var(--bg-tertiary,#f3f3f3)}.bg-\[var\(--border-heavy\)\]{background-color:var(--border-heavy)}.bg-\[var\(--canvas-bg\,var\(--bg-primary\)\)\]{background-color:var(--canvas-bg,var(--bg-primary))}.bg-\[var\(--constant-background\)\]{background-color:var(--constant-background)}.bg-\[var\(--gray-950\)\]{background-color:var(--gray-950)}.bg-\[var\(--green-25\)\]{background-color:var(--green-25)}.bg-\[var\(--main-surface-primary\)\]{background-color:var(--main-surface-primary)}.bg-\[var\(--prompt-icon-bg\)\]{background-color:var(--prompt-icon-bg)}.bg-\[var\(--right-bg\)\]{background-color:var(--right-bg)}.bg-\[var\(--scrollbar-color\)\]{background-color:var(--scrollbar-color)}.bg-\[var\(--theme-user-msg-bg\)\]{background-color:var(--theme-user-msg-bg)}.bg-black{background-color:#000}.bg-black\!{background-color:#000!important}.bg-black\/3{background-color:#00000008;background-color:lab(0% 0 0/.03)}.bg-black\/5{background-color:#0000000d;background-color:lab(0% 0 0/.05)}.bg-black\/5\!{background-color:#0000000d!important;background-color:lab(0% 0 0/.05)!important}.bg-black\/7{background-color:#00000012;background-color:lab(0% 0 0/.07)}.bg-black\/8{background-color:#00000014;background-color:lab(0% 0 0/.08)}.bg-black\/10{background-color:#0000001a;background-color:lab(0% 0 0/.1)}.bg-black\/15{background-color:#00000026;background-color:lab(0% 0 0/.15)}.bg-black\/20{background-color:#0003;background-color:lab(0% 0 0/.2)}.bg-black\/25{background-color:#00000040;background-color:lab(0% 0 0/.25)}.bg-black\/30{background-color:#0000004d;background-color:lab(0% 0 0/.3)}.bg-black\/35{background-color:#00000059;background-color:lab(0% 0 0/.35)}.bg-black\/40{background-color:#0006;background-color:lab(0% 0 0/.4)}.bg-black\/45{background-color:#00000073;background-color:lab(0% 0 0/.45)}.bg-black\/50{background-color:#00000080;background-color:lab(0% 0 0/.5)}.bg-black\/60{background-color:#0009;background-color:lab(0% 0 0/.6)}.bg-black\/70{background-color:#000000b3;background-color:lab(0% 0 0/.7)}.bg-black\/75{background-color:#000000bf;background-color:lab(0% 0 0/.75)}.bg-black\/80{background-color:#000c;background-color:lab(0% 0 0/.8)}.bg-black\/90{background-color:#000000e6;background-color:lab(0% 0 0/.9)}.bg-black\/95{background-color:#000000f2;background-color:lab(0% 0 0/.95)}.bg-black\/\[0\.075\]{background-color:#00000013;background-color:lab(0% 0 0/.075)}.bg-blue-25{background-color:var(--blue-25)}.bg-blue-50{background-color:var(--blue-50)}.bg-blue-75{background-color:var(--blue-75)}.bg-blue-100,.bg-blue-100\/45{background-color:var(--blue-100)}@supports (color:color-mix(in lab, red, red)){.bg-blue-100\/45{background-color:color-mix(in oklab,var(--blue-100)45%,transparent)}}.bg-blue-100\/50{background-color:var(--blue-100)}@supports (color:color-mix(in lab, red, red)){.bg-blue-100\/50{background-color:color-mix(in oklab,var(--blue-100)50%,transparent)}}.bg-blue-200{background-color:var(--blue-200)}.bg-blue-300{background-color:var(--blue-300)}.bg-blue-400,.bg-blue-400\/10{background-color:var(--blue-400)}@supports (color:color-mix(in lab, red, red)){.bg-blue-400\/10{background-color:color-mix(in oklab,var(--blue-400)10%,transparent)}}.bg-blue-400\/10\!{background-color:var(--blue-400)!important}@supports (color:color-mix(in lab, red, red)){.bg-blue-400\/10\!{background-color:color-mix(in oklab,var(--blue-400)10%,transparent)!important}}.bg-blue-400\/15{background-color:var(--blue-400)}@supports (color:color-mix(in lab, red, red)){.bg-blue-400\/15{background-color:color-mix(in oklab,var(--blue-400)15%,transparent)}}.bg-blue-400\/\[\.08\]{background-color:var(--blue-400)}@supports (color:color-mix(in lab, red, red)){.bg-blue-400\/\[\.08\]{background-color:color-mix(in oklab,var(--blue-400)8%,transparent)}}.bg-blue-400\/\[0\.1\]{background-color:var(--blue-400)}@supports (color:color-mix(in lab, red, red)){.bg-blue-400\/\[0\.1\]{background-color:color-mix(in oklab,var(--blue-400)10%,transparent)}}.bg-blue-500,.bg-blue-500\/10{background-color:var(--blue-500)}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab,var(--blue-500)10%,transparent)}}.bg-blue-500\/15{background-color:var(--blue-500)}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/15{background-color:color-mix(in oklab,var(--blue-500)15%,transparent)}}.bg-blue-500\/30{background-color:var(--blue-500)}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/30{background-color:color-mix(in oklab,var(--blue-500)30%,transparent)}}.bg-blue-600{background-color:var(--blue-600)}.bg-blue-700{background-color:var(--blue-700)}.bg-blue-800{background-color:var(--blue-800)}.bg-blue-900{background-color:var(--blue-900)}.bg-blue-950{background-color:var(--blue-950)}.bg-blue-1000{background-color:var(--blue-1000)}.bg-blue-a25{background-color:var(--blue-a25)}.bg-blue-a50{background-color:var(--blue-a50)}.bg-blue-a75{background-color:var(--blue-a75)}.bg-blue-a100{background-color:var(--blue-a100)}.bg-blue-a200{background-color:var(--blue-a200)}.bg-blue-a300{background-color:var(--blue-a300)}.bg-brand-blue-800{background-color:#0066de}.bg-brand-blue-800\/20{background-color:#0066de33;background-color:lab(44.1658% 12.9254 -69.32/.2)}.bg-brand-green{background-color:#19c37d}.bg-brand-purple{background-color:#ab68ff}.bg-brand-purple-600{background-color:#715fde}.bg-brand-purple-800{background-color:#5400de}.bg-current,.bg-current\/20{background-color:currentColor}@supports (color:color-mix(in lab, red, red)){.bg-current\/20{background-color:color-mix(in oklab,currentcolor 20%,transparent)}}.bg-gray-50,.bg-gray-50\/50{background-color:var(--gray-50)}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/50{background-color:color-mix(in oklab,var(--gray-50)50%,transparent)}}.bg-gray-50\/75{background-color:var(--gray-50)}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/75{background-color:color-mix(in oklab,var(--gray-50)75%,transparent)}}.bg-gray-100,.bg-gray-100\/50{background-color:var(--gray-100)}@supports (color:color-mix(in lab, red, red)){.bg-gray-100\/50{background-color:color-mix(in oklab,var(--gray-100)50%,transparent)}}.bg-gray-200{background-color:var(--gray-200)}.bg-gray-200\!{background-color:var(--gray-200)!important}.bg-gray-300{background-color:var(--gray-300)}.bg-gray-300\!{background-color:var(--gray-300)!important}.bg-gray-300\/60{background-color:var(--gray-300)}@supports (color:color-mix(in lab, red, red)){.bg-gray-300\/60{background-color:color-mix(in oklab,var(--gray-300)60%,transparent)}}.bg-gray-400{background-color:var(--gray-400)}.bg-gray-500,.bg-gray-500\/15{background-color:var(--gray-500)}@supports (color:color-mix(in lab, red, red)){.bg-gray-500\/15{background-color:color-mix(in oklab,var(--gray-500)15%,transparent)}}.bg-gray-500\/20{background-color:var(--gray-500)}@supports (color:color-mix(in lab, red, red)){.bg-gray-500\/20{background-color:color-mix(in oklab,var(--gray-500)20%,transparent)}}.bg-gray-500\/30{background-color:var(--gray-500)}@supports (color:color-mix(in lab, red, red)){.bg-gray-500\/30{background-color:color-mix(in oklab,var(--gray-500)30%,transparent)}}.bg-gray-600{background-color:var(--gray-600)}.bg-gray-700{background-color:var(--gray-700)}.bg-gray-800,.bg-gray-800\/60{background-color:var(--gray-800)}@supports (color:color-mix(in lab, red, red)){.bg-gray-800\/60{background-color:color-mix(in oklab,var(--gray-800)60%,transparent)}}.bg-gray-900,.bg-gray-900\/20{background-color:var(--gray-900)}@supports (color:color-mix(in lab, red, red)){.bg-gray-900\/20{background-color:color-mix(in oklab,var(--gray-900)20%,transparent)}}.bg-gray-900\/70{background-color:var(--gray-900)}@supports (color:color-mix(in lab, red, red)){.bg-gray-900\/70{background-color:color-mix(in oklab,var(--gray-900)70%,transparent)}}.bg-gray-950,.bg-gray-950\/5{background-color:var(--gray-950)}@supports (color:color-mix(in lab, red, red)){.bg-gray-950\/5{background-color:color-mix(in oklab,var(--gray-950)5%,transparent)}}.bg-gray-solid-0{background-color:#fff}.bg-gray-solid-25{background-color:#fcfcfc}.bg-gray-solid-50{background-color:#f9f9f9}.bg-gray-solid-75{background-color:#f3f3f3}.bg-gray-solid-75\!{background-color:#f3f3f3!important}.bg-gray-solid-100{background-color:#e8e8e8}.bg-gray-solid-150{background-color:#dfdfdf}.bg-gray-solid-200{background-color:#cdcdcd}.bg-gray-solid-250{background-color:#b9b9b9}.bg-gray-solid-300{background-color:#afafaf}.bg-gray-solid-350{background-color:#9f9f9f}.bg-gray-solid-400{background-color:#8f8f8f}.bg-gray-solid-450{background-color:#767676}.bg-gray-solid-500{background-color:#5d5d5d}.bg-gray-solid-550{background-color:#4f4f4f}.bg-gray-solid-600{background-color:#414141}.bg-gray-solid-650{background-color:#393939}.bg-gray-solid-700{background-color:#303030}.bg-gray-solid-750{background-color:#282828}.bg-gray-solid-800{background-color:#212121}.bg-gray-solid-850{background-color:#1c1c1c}.bg-gray-solid-900{background-color:#181818}.bg-gray-solid-925{background-color:#161616}.bg-gray-solid-950{background-color:#131313}.bg-gray-solid-975{background-color:#101010}.bg-gray-solid-1000{background-color:#0d0d0d}.bg-gray-solid-1000\/10{background-color:#0d0d0d1a;background-color:lab(3.63549% -.00000745058 .00000298023/.1)}.bg-green-25{background-color:var(--green-25)}.bg-green-50,.bg-green-50\/60{background-color:var(--green-50)}@supports (color:color-mix(in lab, red, red)){.bg-green-50\/60{background-color:color-mix(in oklab,var(--green-50)60%,transparent)}}.bg-green-75{background-color:var(--green-75)}.bg-green-100{background-color:var(--green-100)}.bg-green-100\!{background-color:var(--green-100)!important}.bg-green-100\/80{background-color:var(--green-100)}@supports (color:color-mix(in lab, red, red)){.bg-green-100\/80{background-color:color-mix(in oklab,var(--green-100)80%,transparent)}}.bg-green-200{background-color:var(--green-200)}.bg-green-300{background-color:var(--green-300)}.bg-green-400{background-color:var(--green-400)}.bg-green-400\!{background-color:var(--green-400)!important}.bg-green-500,.bg-green-500\/10{background-color:var(--green-500)}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/10{background-color:color-mix(in oklab,var(--green-500)10%,transparent)}}.bg-green-500\/15{background-color:var(--green-500)}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/15{background-color:color-mix(in oklab,var(--green-500)15%,transparent)}}.bg-green-500\/20{background-color:var(--green-500)}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/20{background-color:color-mix(in oklab,var(--green-500)20%,transparent)}}.bg-green-500\/30{background-color:var(--green-500)}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/30{background-color:color-mix(in oklab,var(--green-500)30%,transparent)}}.bg-green-600,.bg-green-600\/10{background-color:var(--green-600)}@supports (color:color-mix(in lab, red, red)){.bg-green-600\/10{background-color:color-mix(in oklab,var(--green-600)10%,transparent)}}.bg-green-600\/30{background-color:var(--green-600)}@supports (color:color-mix(in lab, red, red)){.bg-green-600\/30{background-color:color-mix(in oklab,var(--green-600)30%,transparent)}}.bg-green-700,.bg-green-700\/15{background-color:var(--green-700)}@supports (color:color-mix(in lab, red, red)){.bg-green-700\/15{background-color:color-mix(in oklab,var(--green-700)15%,transparent)}}.bg-green-800{background-color:var(--green-800)}.bg-green-900{background-color:var(--green-900)}.bg-green-950{background-color:var(--green-950)}.bg-green-1000{background-color:var(--green-1000)}.bg-green-a25{background-color:var(--green-a25)}.bg-green-a50{background-color:var(--green-a50)}.bg-green-a75{background-color:var(--green-a75)}.bg-green-a100{background-color:var(--green-a100)}.bg-green-a200{background-color:var(--green-a200)}.bg-green-a300{background-color:var(--green-a300)}.bg-inherit{background-color:inherit}.bg-orange-25{background-color:var(--orange-25)}.bg-orange-50{background-color:var(--orange-50)}.bg-orange-75{background-color:var(--orange-75)}.bg-orange-100{background-color:var(--orange-100)}.bg-orange-200{background-color:var(--orange-200)}.bg-orange-300{background-color:var(--orange-300)}.bg-orange-400,.bg-orange-400\/5{background-color:var(--orange-400)}@supports (color:color-mix(in lab, red, red)){.bg-orange-400\/5{background-color:color-mix(in oklab,var(--orange-400)5%,transparent)}}.bg-orange-400\/10{background-color:var(--orange-400)}@supports (color:color-mix(in lab, red, red)){.bg-orange-400\/10{background-color:color-mix(in oklab,var(--orange-400)10%,transparent)}}.bg-orange-500,.bg-orange-500\/10{background-color:var(--orange-500)}@supports (color:color-mix(in lab, red, red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--orange-500)10%,transparent)}}.bg-orange-600,.bg-orange-600\/10{background-color:var(--orange-600)}@supports (color:color-mix(in lab, red, red)){.bg-orange-600\/10{background-color:color-mix(in oklab,var(--orange-600)10%,transparent)}}.bg-orange-700{background-color:var(--orange-700)}.bg-orange-800{background-color:var(--orange-800)}.bg-orange-900{background-color:var(--orange-900)}.bg-orange-950{background-color:var(--orange-950)}.bg-orange-1000{background-color:var(--orange-1000)}.bg-orange-a25{background-color:var(--orange-a25)}.bg-orange-a50{background-color:var(--orange-a50)}.bg-orange-a75{background-color:var(--orange-a75)}.bg-orange-a100{background-color:var(--orange-a100)}.bg-orange-a200{background-color:var(--orange-a200)}.bg-orange-a300{background-color:var(--orange-a300)}.bg-pink-25{background-color:var(--pink-25)}.bg-pink-50{background-color:var(--pink-50)}.bg-pink-75{background-color:var(--pink-75)}.bg-pink-100{background-color:var(--pink-100)}.bg-pink-200{background-color:var(--pink-200)}.bg-pink-300{background-color:var(--pink-300)}.bg-pink-400{background-color:var(--pink-400)}.bg-pink-500{background-color:var(--pink-500)}.bg-pink-600{background-color:var(--pink-600)}.bg-pink-700{background-color:var(--pink-700)}.bg-pink-800{background-color:var(--pink-800)}.bg-pink-900{background-color:var(--pink-900)}.bg-pink-950{background-color:var(--pink-950)}.bg-pink-1000{background-color:var(--pink-1000)}.bg-pink-a25{background-color:var(--pink-a25)}.bg-pink-a50{background-color:var(--pink-a50)}.bg-pink-a75{background-color:var(--pink-a75)}.bg-pink-a100{background-color:var(--pink-a100)}.bg-pink-a200{background-color:var(--pink-a200)}.bg-pink-a300{background-color:var(--pink-a300)}.bg-purple-25{background-color:var(--purple-25)}.bg-purple-50{background-color:var(--purple-50)}.bg-purple-75{background-color:var(--purple-75)}.bg-purple-100{background-color:var(--purple-100)}.bg-purple-200{background-color:var(--purple-200)}.bg-purple-300{background-color:var(--purple-300)}.bg-purple-400,.bg-purple-400\/10{background-color:var(--purple-400)}@supports (color:color-mix(in lab, red, red)){.bg-purple-400\/10{background-color:color-mix(in oklab,var(--purple-400)10%,transparent)}}.bg-purple-400\/15{background-color:var(--purple-400)}@supports (color:color-mix(in lab, red, red)){.bg-purple-400\/15{background-color:color-mix(in oklab,var(--purple-400)15%,transparent)}}.bg-purple-500{background-color:var(--purple-500)}.bg-purple-600{background-color:var(--purple-600)}.bg-purple-700{background-color:var(--purple-700)}.bg-purple-800{background-color:var(--purple-800)}.bg-purple-900{background-color:var(--purple-900)}.bg-purple-950{background-color:var(--purple-950)}.bg-purple-1000{background-color:var(--purple-1000)}.bg-purple-a25{background-color:var(--purple-a25)}.bg-purple-a50{background-color:var(--purple-a50)}.bg-purple-a75{background-color:var(--purple-a75)}.bg-purple-a100{background-color:var(--purple-a100)}.bg-purple-a200{background-color:var(--purple-a200)}.bg-purple-a300{background-color:var(--purple-a300)}.bg-red-25{background-color:var(--red-25)}.bg-red-50,.bg-red-50\/60{background-color:var(--red-50)}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/60{background-color:color-mix(in oklab,var(--red-50)60%,transparent)}}.bg-red-75{background-color:var(--red-75)}.bg-red-100{background-color:var(--red-100)}.bg-red-100\!{background-color:var(--red-100)!important}.bg-red-100\/80{background-color:var(--red-100)}@supports (color:color-mix(in lab, red, red)){.bg-red-100\/80{background-color:color-mix(in oklab,var(--red-100)80%,transparent)}}.bg-red-200,.bg-red-200\/70{background-color:var(--red-200)}@supports (color:color-mix(in lab, red, red)){.bg-red-200\/70{background-color:color-mix(in oklab,var(--red-200)70%,transparent)}}.bg-red-300{background-color:var(--red-300)}.bg-red-400{background-color:var(--red-400)}.bg-red-400\!{background-color:var(--red-400)!important}.bg-red-500,.bg-red-500\/5{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/5{background-color:color-mix(in oklab,var(--red-500)5%,transparent)}}.bg-red-500\/10{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--red-500)10%,transparent)}}.bg-red-500\/15{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/15{background-color:color-mix(in oklab,var(--red-500)15%,transparent)}}.bg-red-500\/20{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/20{background-color:color-mix(in oklab,var(--red-500)20%,transparent)}}.bg-red-500\/30{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/30{background-color:color-mix(in oklab,var(--red-500)30%,transparent)}}.bg-red-600{background-color:var(--red-600)}.bg-red-700,.bg-red-700\/15{background-color:var(--red-700)}@supports (color:color-mix(in lab, red, red)){.bg-red-700\/15{background-color:color-mix(in oklab,var(--red-700)15%,transparent)}}.bg-red-800{background-color:var(--red-800)}.bg-red-900,.bg-red-900\/20{background-color:var(--red-900)}@supports (color:color-mix(in lab, red, red)){.bg-red-900\/20{background-color:color-mix(in oklab,var(--red-900)20%,transparent)}}.bg-red-900\/40{background-color:var(--red-900)}@supports (color:color-mix(in lab, red, red)){.bg-red-900\/40{background-color:color-mix(in oklab,var(--red-900)40%,transparent)}}.bg-red-950{background-color:var(--red-950)}.bg-red-1000{background-color:var(--red-1000)}.bg-red-a25{background-color:var(--red-a25)}.bg-red-a50{background-color:var(--red-a50)}.bg-red-a75{background-color:var(--red-a75)}.bg-red-a100{background-color:var(--red-a100)}.bg-red-a200{background-color:var(--red-a200)}.bg-red-a300{background-color:var(--red-a300)}.bg-token-bg-accent-static{background-color:var(--bg-accent-static)}.bg-token-bg-elevated-primary{background-color:var(--bg-elevated-primary)}.bg-token-bg-elevated-secondary{background-color:var(--bg-elevated-secondary)}.bg-token-bg-elevated-secondary\!{background-color:var(--bg-elevated-secondary)!important}.bg-token-bg-elevated-secondary\/50{background-color:var(--bg-elevated-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-elevated-secondary\/50{background-color:color-mix(in oklab,var(--bg-elevated-secondary)50%,transparent)}}.bg-token-bg-elevated-secondary\/60{background-color:var(--bg-elevated-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-elevated-secondary\/60{background-color:color-mix(in oklab,var(--bg-elevated-secondary)60%,transparent)}}.bg-token-bg-elevated-secondary\/90{background-color:var(--bg-elevated-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-elevated-secondary\/90{background-color:color-mix(in oklab,var(--bg-elevated-secondary)90%,transparent)}}.bg-token-bg-primary{background-color:var(--bg-primary)}.bg-token-bg-primary\!{background-color:var(--bg-primary)!important}.bg-token-bg-primary-inverted{background-color:var(--bg-primary-inverted)}.bg-token-bg-primary\/0{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/0{background-color:color-mix(in oklab,var(--bg-primary)0%,transparent)}}.bg-token-bg-primary\/10{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/10{background-color:color-mix(in oklab,var(--bg-primary)10%,transparent)}}.bg-token-bg-primary\/40\!{background-color:var(--bg-primary)!important}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/40\!{background-color:color-mix(in oklab,var(--bg-primary)40%,transparent)!important}}.bg-token-bg-primary\/50{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/50{background-color:color-mix(in oklab,var(--bg-primary)50%,transparent)}}.bg-token-bg-primary\/52{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/52{background-color:color-mix(in oklab,var(--bg-primary)52%,transparent)}}.bg-token-bg-primary\/55{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/55{background-color:color-mix(in oklab,var(--bg-primary)55%,transparent)}}.bg-token-bg-primary\/60{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/60{background-color:color-mix(in oklab,var(--bg-primary)60%,transparent)}}.bg-token-bg-primary\/70{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/70{background-color:color-mix(in oklab,var(--bg-primary)70%,transparent)}}.bg-token-bg-primary\/80{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/80{background-color:color-mix(in oklab,var(--bg-primary)80%,transparent)}}.bg-token-bg-primary\/85{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/85{background-color:color-mix(in oklab,var(--bg-primary)85%,transparent)}}.bg-token-bg-primary\/90{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/90{background-color:color-mix(in oklab,var(--bg-primary)90%,transparent)}}.bg-token-bg-primary\/95{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/95{background-color:color-mix(in oklab,var(--bg-primary)95%,transparent)}}.bg-token-bg-primary\/98{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-primary\/98{background-color:color-mix(in oklab,var(--bg-primary)98%,transparent)}}.bg-token-bg-scrim{background-color:var(--bg-scrim)}.bg-token-bg-secondary{background-color:var(--bg-secondary)}.bg-token-bg-secondary\!{background-color:var(--bg-secondary)!important}.bg-token-bg-secondary\/10{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-secondary\/10{background-color:color-mix(in oklab,var(--bg-secondary)10%,transparent)}}.bg-token-bg-secondary\/20{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-secondary\/20{background-color:color-mix(in oklab,var(--bg-secondary)20%,transparent)}}.bg-token-bg-secondary\/30{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-secondary\/30{background-color:color-mix(in oklab,var(--bg-secondary)30%,transparent)}}.bg-token-bg-secondary\/40{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-secondary\/40{background-color:color-mix(in oklab,var(--bg-secondary)40%,transparent)}}.bg-token-bg-secondary\/50{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-secondary\/50{background-color:color-mix(in oklab,var(--bg-secondary)50%,transparent)}}.bg-token-bg-secondary\/60{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-secondary\/60{background-color:color-mix(in oklab,var(--bg-secondary)60%,transparent)}}.bg-token-bg-secondary\/70{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-secondary\/70{background-color:color-mix(in oklab,var(--bg-secondary)70%,transparent)}}.bg-token-bg-secondary\/75{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-secondary\/75{background-color:color-mix(in oklab,var(--bg-secondary)75%,transparent)}}.bg-token-bg-secondary\/80{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-secondary\/80{background-color:color-mix(in oklab,var(--bg-secondary)80%,transparent)}}.bg-token-bg-status-error,.bg-token-bg-status-error\/10{background-color:var(--bg-status-error)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-status-error\/10{background-color:color-mix(in oklab,var(--bg-status-error)10%,transparent)}}.bg-token-bg-status-warning{background-color:var(--bg-status-warning)}.bg-token-bg-tertiary{background-color:var(--bg-tertiary)}.bg-token-bg-tertiary\!{background-color:var(--bg-tertiary)!important}.bg-token-bg-tertiary\/20{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-tertiary\/20{background-color:color-mix(in oklab,var(--bg-tertiary)20%,transparent)}}.bg-token-bg-tertiary\/40{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-tertiary\/40{background-color:color-mix(in oklab,var(--bg-tertiary)40%,transparent)}}.bg-token-bg-tertiary\/50{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-tertiary\/50{background-color:color-mix(in oklab,var(--bg-tertiary)50%,transparent)}}.bg-token-bg-tertiary\/60{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-tertiary\/60{background-color:color-mix(in oklab,var(--bg-tertiary)60%,transparent)}}.bg-token-bg-tertiary\/70{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-tertiary\/70{background-color:color-mix(in oklab,var(--bg-tertiary)70%,transparent)}}.bg-token-bg-tertiary\/75{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-tertiary\/75{background-color:color-mix(in oklab,var(--bg-tertiary)75%,transparent)}}.bg-token-bg-tertiary\/80{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.bg-token-bg-tertiary\/80{background-color:color-mix(in oklab,var(--bg-tertiary)80%,transparent)}}.bg-token-border-default{background-color:var(--border-default)}.bg-token-border-heavy{background-color:var(--border-heavy)}.bg-token-border-light,.bg-token-border-light\/40{background-color:var(--border-light)}@supports (color:color-mix(in lab, red, red)){.bg-token-border-light\/40{background-color:color-mix(in oklab,var(--border-light)40%,transparent)}}.bg-token-border-medium{background-color:var(--border-medium)}.bg-token-border-sharp{background-color:var(--border-sharp)}.bg-token-border-status-error{background-color:var(--border-status-error)}.bg-token-border-status-warning{background-color:var(--border-status-warning)}.bg-token-border-xlight{background-color:var(--border-xlight)}.bg-token-composer-blue-bg{background-color:var(--composer-blue-bg)}.bg-token-composer-surface{background-color:var(--composer-surface)}.bg-token-hint-bg{background-color:var(--hint-bg)}.bg-token-icon-accent,.bg-token-icon-accent\/50{background-color:var(--icon-accent)}@supports (color:color-mix(in lab, red, red)){.bg-token-icon-accent\/50{background-color:color-mix(in oklab,var(--icon-accent)50%,transparent)}}.bg-token-icon-inverted{background-color:var(--icon-inverted)}.bg-token-icon-inverted-static{background-color:var(--icon-inverted-static)}.bg-token-icon-primary{background-color:var(--icon-primary)}.bg-token-icon-secondary{background-color:var(--icon-secondary)}.bg-token-icon-status-error{background-color:var(--icon-status-error)}.bg-token-icon-status-warning{background-color:var(--icon-status-warning)}.bg-token-icon-tertiary{background-color:var(--icon-tertiary)}.bg-token-interactive-bg-accent-default{background-color:var(--interactive-bg-accent-default)}.bg-token-interactive-bg-accent-hover{background-color:var(--interactive-bg-accent-hover)}.bg-token-interactive-bg-accent-inactive{background-color:var(--interactive-bg-accent-inactive)}.bg-token-interactive-bg-accent-muted-context,.bg-token-interactive-bg-accent-muted-context\/50{background-color:var(--interactive-bg-accent-muted-context)}@supports (color:color-mix(in lab, red, red)){.bg-token-interactive-bg-accent-muted-context\/50{background-color:color-mix(in oklab,var(--interactive-bg-accent-muted-context)50%,transparent)}}.bg-token-interactive-bg-accent-muted-hover{background-color:var(--interactive-bg-accent-muted-hover)}.bg-token-interactive-bg-accent-muted-press{background-color:var(--interactive-bg-accent-muted-press)}.bg-token-interactive-bg-accent-press{background-color:var(--interactive-bg-accent-press)}.bg-token-interactive-bg-danger-primary-default{background-color:var(--interactive-bg-danger-primary-default)}.bg-token-interactive-bg-danger-primary-hover{background-color:var(--interactive-bg-danger-primary-hover)}.bg-token-interactive-bg-danger-primary-inactive{background-color:var(--interactive-bg-danger-primary-inactive)}.bg-token-interactive-bg-danger-primary-press{background-color:var(--interactive-bg-danger-primary-press)}.bg-token-interactive-bg-danger-secondary-default{background-color:var(--interactive-bg-danger-secondary-default)}.bg-token-interactive-bg-danger-secondary-hover{background-color:var(--interactive-bg-danger-secondary-hover)}.bg-token-interactive-bg-danger-secondary-inactive{background-color:var(--interactive-bg-danger-secondary-inactive)}.bg-token-interactive-bg-danger-secondary-press{background-color:var(--interactive-bg-danger-secondary-press)}.bg-token-interactive-bg-primary-default{background-color:var(--interactive-bg-primary-default)}.bg-token-interactive-bg-primary-hover{background-color:var(--interactive-bg-primary-hover)}.bg-token-interactive-bg-primary-inactive{background-color:var(--interactive-bg-primary-inactive)}.bg-token-interactive-bg-primary-press{background-color:var(--interactive-bg-primary-press)}.bg-token-interactive-bg-primary-selected{background-color:var(--interactive-bg-primary-selected)}.bg-token-interactive-bg-secondary-default{background-color:var(--interactive-bg-secondary-default)}.bg-token-interactive-bg-secondary-hover{background-color:var(--interactive-bg-secondary-hover)}.bg-token-interactive-bg-secondary-inactive{background-color:var(--interactive-bg-secondary-inactive)}.bg-token-interactive-bg-secondary-press{background-color:var(--interactive-bg-secondary-press)}.bg-token-interactive-bg-secondary-selected{background-color:var(--interactive-bg-secondary-selected)}.bg-token-interactive-bg-tertiary-default{background-color:var(--interactive-bg-tertiary-default)}.bg-token-interactive-bg-tertiary-hover{background-color:var(--interactive-bg-tertiary-hover)}.bg-token-interactive-bg-tertiary-inactive{background-color:var(--interactive-bg-tertiary-inactive)}.bg-token-interactive-bg-tertiary-press{background-color:var(--interactive-bg-tertiary-press)}.bg-token-interactive-bg-tertiary-selected{background-color:var(--interactive-bg-tertiary-selected)}.bg-token-interactive-border-danger-secondary-default{background-color:var(--interactive-border-danger-secondary-default)}.bg-token-interactive-border-danger-secondary-hover{background-color:var(--interactive-border-danger-secondary-hover)}.bg-token-interactive-border-danger-secondary-inactive{background-color:var(--interactive-border-danger-secondary-inactive)}.bg-token-interactive-border-danger-secondary-press{background-color:var(--interactive-border-danger-secondary-press)}.bg-token-interactive-border-focus{background-color:var(--interactive-border-focus)}.bg-token-interactive-border-secondary-default{background-color:var(--interactive-border-secondary-default)}.bg-token-interactive-border-secondary-hover{background-color:var(--interactive-border-secondary-hover)}.bg-token-interactive-border-secondary-inactive{background-color:var(--interactive-border-secondary-inactive)}.bg-token-interactive-border-secondary-press{background-color:var(--interactive-border-secondary-press)}.bg-token-interactive-border-tertiary-default{background-color:var(--interactive-border-tertiary-default)}.bg-token-interactive-border-tertiary-hover{background-color:var(--interactive-border-tertiary-hover)}.bg-token-interactive-border-tertiary-inactive{background-color:var(--interactive-border-tertiary-inactive)}.bg-token-interactive-border-tertiary-press{background-color:var(--interactive-border-tertiary-press)}.bg-token-interactive-icon-accent-default{background-color:var(--interactive-icon-accent-default)}.bg-token-interactive-icon-accent-hover{background-color:var(--interactive-icon-accent-hover)}.bg-token-interactive-icon-accent-inactive{background-color:var(--interactive-icon-accent-inactive)}.bg-token-interactive-icon-accent-press{background-color:var(--interactive-icon-accent-press)}.bg-token-interactive-icon-accent-selected{background-color:var(--interactive-icon-accent-selected)}.bg-token-interactive-icon-danger-primary-default{background-color:var(--interactive-icon-danger-primary-default)}.bg-token-interactive-icon-danger-primary-hover{background-color:var(--interactive-icon-danger-primary-hover)}.bg-token-interactive-icon-danger-primary-inactive{background-color:var(--interactive-icon-danger-primary-inactive)}.bg-token-interactive-icon-danger-primary-press{background-color:var(--interactive-icon-danger-primary-press)}.bg-token-interactive-icon-danger-secondary-default{background-color:var(--interactive-icon-danger-secondary-default)}.bg-token-interactive-icon-danger-secondary-hover{background-color:var(--interactive-icon-danger-secondary-hover)}.bg-token-interactive-icon-danger-secondary-inactive{background-color:var(--interactive-icon-danger-secondary-inactive)}.bg-token-interactive-icon-danger-secondary-press{background-color:var(--interactive-icon-danger-secondary-press)}.bg-token-interactive-icon-primary-default{background-color:var(--interactive-icon-primary-default)}.bg-token-interactive-icon-primary-hover{background-color:var(--interactive-icon-primary-hover)}.bg-token-interactive-icon-primary-inactive{background-color:var(--interactive-icon-primary-inactive)}.bg-token-interactive-icon-primary-press{background-color:var(--interactive-icon-primary-press)}.bg-token-interactive-icon-primary-selected{background-color:var(--interactive-icon-primary-selected)}.bg-token-interactive-icon-secondary-default{background-color:var(--interactive-icon-secondary-default)}.bg-token-interactive-icon-secondary-hover{background-color:var(--interactive-icon-secondary-hover)}.bg-token-interactive-icon-secondary-inactive{background-color:var(--interactive-icon-secondary-inactive)}.bg-token-interactive-icon-secondary-press{background-color:var(--interactive-icon-secondary-press)}.bg-token-interactive-icon-secondary-selected{background-color:var(--interactive-icon-secondary-selected)}.bg-token-interactive-icon-tertiary-default{background-color:var(--interactive-icon-tertiary-default)}.bg-token-interactive-icon-tertiary-hover{background-color:var(--interactive-icon-tertiary-hover)}.bg-token-interactive-icon-tertiary-inactive{background-color:var(--interactive-icon-tertiary-inactive)}.bg-token-interactive-icon-tertiary-press{background-color:var(--interactive-icon-tertiary-press)}.bg-token-interactive-icon-tertiary-selected{background-color:var(--interactive-icon-tertiary-selected)}.bg-token-interactive-label-accent-default{background-color:var(--interactive-label-accent-default)}.bg-token-interactive-label-accent-hover{background-color:var(--interactive-label-accent-hover)}.bg-token-interactive-label-accent-inactive{background-color:var(--interactive-label-accent-inactive)}.bg-token-interactive-label-accent-press{background-color:var(--interactive-label-accent-press)}.bg-token-interactive-label-accent-selected{background-color:var(--interactive-label-accent-selected)}.bg-token-interactive-label-danger-primary-default{background-color:var(--interactive-label-danger-primary-default)}.bg-token-interactive-label-danger-primary-hover{background-color:var(--interactive-label-danger-primary-hover)}.bg-token-interactive-label-danger-primary-inactive{background-color:var(--interactive-label-danger-primary-inactive)}.bg-token-interactive-label-danger-primary-press{background-color:var(--interactive-label-danger-primary-press)}.bg-token-interactive-label-danger-secondary-default{background-color:var(--interactive-label-danger-secondary-default)}.bg-token-interactive-label-danger-secondary-hover{background-color:var(--interactive-label-danger-secondary-hover)}.bg-token-interactive-label-danger-secondary-inactive{background-color:var(--interactive-label-danger-secondary-inactive)}.bg-token-interactive-label-danger-secondary-press{background-color:var(--interactive-label-danger-secondary-press)}.bg-token-interactive-label-primary-default{background-color:var(--interactive-label-primary-default)}.bg-token-interactive-label-primary-hover{background-color:var(--interactive-label-primary-hover)}.bg-token-interactive-label-primary-inactive{background-color:var(--interactive-label-primary-inactive)}.bg-token-interactive-label-primary-press{background-color:var(--interactive-label-primary-press)}.bg-token-interactive-label-primary-selected{background-color:var(--interactive-label-primary-selected)}.bg-token-interactive-label-secondary-default{background-color:var(--interactive-label-secondary-default)}.bg-token-interactive-label-secondary-hover{background-color:var(--interactive-label-secondary-hover)}.bg-token-interactive-label-secondary-inactive{background-color:var(--interactive-label-secondary-inactive)}.bg-token-interactive-label-secondary-press{background-color:var(--interactive-label-secondary-press)}.bg-token-interactive-label-secondary-selected{background-color:var(--interactive-label-secondary-selected)}.bg-token-interactive-label-tertiary-default{background-color:var(--interactive-label-tertiary-default)}.bg-token-interactive-label-tertiary-hover{background-color:var(--interactive-label-tertiary-hover)}.bg-token-interactive-label-tertiary-inactive{background-color:var(--interactive-label-tertiary-inactive)}.bg-token-interactive-label-tertiary-press{background-color:var(--interactive-label-tertiary-press)}.bg-token-interactive-label-tertiary-selected{background-color:var(--interactive-label-tertiary-selected)}.bg-token-main-surface-primary{background-color:var(--main-surface-primary)}.bg-token-main-surface-primary\!{background-color:var(--main-surface-primary)!important}.bg-token-main-surface-primary-inverse{background-color:var(--main-surface-primary-inverse)}.bg-token-main-surface-primary\/10{background-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-primary\/10{background-color:color-mix(in oklab,var(--main-surface-primary)10%,transparent)}}.bg-token-main-surface-primary\/20\!{background-color:var(--main-surface-primary)!important}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-primary\/20\!{background-color:color-mix(in oklab,var(--main-surface-primary)20%,transparent)!important}}.bg-token-main-surface-primary\/40{background-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-primary\/40{background-color:color-mix(in oklab,var(--main-surface-primary)40%,transparent)}}.bg-token-main-surface-primary\/60{background-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-primary\/60{background-color:color-mix(in oklab,var(--main-surface-primary)60%,transparent)}}.bg-token-main-surface-primary\/70{background-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-primary\/70{background-color:color-mix(in oklab,var(--main-surface-primary)70%,transparent)}}.bg-token-main-surface-primary\/80{background-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-primary\/80{background-color:color-mix(in oklab,var(--main-surface-primary)80%,transparent)}}.bg-token-main-surface-primary\/90{background-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-primary\/90{background-color:color-mix(in oklab,var(--main-surface-primary)90%,transparent)}}.bg-token-main-surface-primary\/95{background-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-primary\/95{background-color:color-mix(in oklab,var(--main-surface-primary)95%,transparent)}}.bg-token-main-surface-secondary{background-color:var(--main-surface-secondary)}.bg-token-main-surface-secondary\!{background-color:var(--main-surface-secondary)!important}.bg-token-main-surface-secondary-selected{background-color:var(--main-surface-secondary-selected)}.bg-token-main-surface-secondary\/40{background-color:var(--main-surface-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-secondary\/40{background-color:color-mix(in oklab,var(--main-surface-secondary)40%,transparent)}}.bg-token-main-surface-secondary\/50{background-color:var(--main-surface-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-secondary\/50{background-color:color-mix(in oklab,var(--main-surface-secondary)50%,transparent)}}.bg-token-main-surface-secondary\/60{background-color:var(--main-surface-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-secondary\/60{background-color:color-mix(in oklab,var(--main-surface-secondary)60%,transparent)}}.bg-token-main-surface-secondary\/70{background-color:var(--main-surface-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-secondary\/70{background-color:color-mix(in oklab,var(--main-surface-secondary)70%,transparent)}}.bg-token-main-surface-secondary\/80{background-color:var(--main-surface-secondary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-secondary\/80{background-color:color-mix(in oklab,var(--main-surface-secondary)80%,transparent)}}.bg-token-main-surface-tertiary{background-color:var(--main-surface-tertiary)}.bg-token-main-surface-tertiary\!{background-color:var(--main-surface-tertiary)!important}.bg-token-main-surface-tertiary\/60{background-color:var(--main-surface-tertiary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-tertiary\/60{background-color:color-mix(in oklab,var(--main-surface-tertiary)60%,transparent)}}.bg-token-main-surface-tertiary\/70{background-color:var(--main-surface-tertiary)}@supports (color:color-mix(in lab, red, red)){.bg-token-main-surface-tertiary\/70{background-color:color-mix(in oklab,var(--main-surface-tertiary)70%,transparent)}}.bg-token-message-surface{background-color:var(--message-surface)}.bg-token-sidebar-surface{background-color:var(--sidebar-surface)}.bg-token-sidebar-surface-primary{background-color:var(--sidebar-surface-primary)}.bg-token-sidebar-surface-secondary{background-color:var(--sidebar-surface-secondary)}.bg-token-sidebar-surface-tertiary{background-color:var(--sidebar-surface-tertiary)}.bg-token-surface-error,.bg-token-surface-error\/5{background-color:rgb(var(--surface-error)/1)}@supports (color:color-mix(in lab, red, red)){.bg-token-surface-error\/5{background-color:color-mix(in oklab,rgb(var(--surface-error)/1)5%,transparent)}}.bg-token-surface-hover{background-color:var(--surface-hover)}.bg-token-text-accent{background-color:var(--text-accent)}.bg-token-text-inverted{background-color:var(--text-inverted)}.bg-token-text-inverted-static{background-color:var(--text-inverted-static)}.bg-token-text-inverted\/20{background-color:var(--text-inverted)}@supports (color:color-mix(in lab, red, red)){.bg-token-text-inverted\/20{background-color:color-mix(in oklab,var(--text-inverted)20%,transparent)}}.bg-token-text-primary{background-color:var(--text-primary)}.bg-token-text-primary\!{background-color:var(--text-primary)!important}.bg-token-text-primary\/4{background-color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.bg-token-text-primary\/4{background-color:color-mix(in oklab,var(--text-primary)4%,transparent)}}.bg-token-text-quaternary{background-color:var(--text-quaternary)}.bg-token-text-secondary{background-color:var(--text-secondary)}.bg-token-text-status-error,.bg-token-text-status-error\/15{background-color:var(--text-status-error)}@supports (color:color-mix(in lab, red, red)){.bg-token-text-status-error\/15{background-color:color-mix(in oklab,var(--text-status-error)15%,transparent)}}.bg-token-text-status-warning,.bg-token-text-status-warning\/15{background-color:var(--text-status-warning)}@supports (color:color-mix(in lab, red, red)){.bg-token-text-status-warning\/15{background-color:color-mix(in oklab,var(--text-status-warning)15%,transparent)}}.bg-token-text-tertiary,.bg-token-text-tertiary\/50{background-color:var(--text-tertiary)}@supports (color:color-mix(in lab, red, red)){.bg-token-text-tertiary\/50{background-color:color-mix(in oklab,var(--text-tertiary)50%,transparent)}}.bg-token-text-tertiary\/60{background-color:var(--text-tertiary)}@supports (color:color-mix(in lab, red, red)){.bg-token-text-tertiary\/60{background-color:color-mix(in oklab,var(--text-tertiary)60%,transparent)}}.bg-token-utility-scrollbar{background-color:var(--utility-scrollbar)}.bg-transparent{background-color:#0000}.bg-transparent\!{background-color:#0000!important}.bg-white{background-color:#fff}.bg-white\!{background-color:#fff!important}.bg-white\/5{background-color:#ffffff0d;background-color:lab(100% -.0000298023 .0000119209/.05)}.bg-white\/10{background-color:#ffffff1a;background-color:lab(100% -.0000298023 .0000119209/.1)}.bg-white\/12{background-color:#ffffff1f;background-color:lab(100% -.0000298023 .0000119209/.12)}.bg-white\/15{background-color:#ffffff26;background-color:lab(100% -.0000298023 .0000119209/.15)}.bg-white\/18{background-color:#ffffff2e;background-color:lab(100% -.0000298023 .0000119209/.18)}.bg-white\/20{background-color:#fff3;background-color:lab(100% -.0000298023 .0000119209/.2)}.bg-white\/20\!{background-color:#fff3!important;background-color:lab(100% -.0000298023 .0000119209/.2)!important}.bg-white\/25{background-color:#ffffff40;background-color:lab(100% -.0000298023 .0000119209/.25)}.bg-white\/30{background-color:#ffffff4d;background-color:lab(100% -.0000298023 .0000119209/.3)}.bg-white\/30\!{background-color:#ffffff4d!important;background-color:lab(100% -.0000298023 .0000119209/.3)!important}.bg-white\/40{background-color:#fff6;background-color:lab(100% -.0000298023 .0000119209/.4)}.bg-white\/60{background-color:#fff9;background-color:lab(100% -.0000298023 .0000119209/.6)}.bg-white\/70{background-color:#ffffffb3;background-color:lab(100% -.0000298023 .0000119209/.7)}.bg-white\/75{background-color:#ffffffbf;background-color:lab(100% -.0000298023 .0000119209/.75)}.bg-white\/80{background-color:#fffc;background-color:lab(100% -.0000298023 .0000119209/.8)}.bg-white\/90{background-color:#ffffffe6;background-color:lab(100% -.0000298023 .0000119209/.9)}.bg-white\/92{background-color:#ffffffeb;background-color:lab(100% -.0000298023 .0000119209/.92)}.bg-white\/95{background-color:#fffffff2;background-color:lab(100% -.0000298023 .0000119209/.95)}.bg-white\/\[0\.02\]{background-color:#ffffff05;background-color:lab(100% -.0000298023 .0000119209/.02)}.bg-white\/\[0\.03\]{background-color:#ffffff08;background-color:lab(100% -.0000298023 .0000119209/.03)}.bg-yellow-25{background-color:var(--yellow-25)}.bg-yellow-50,.bg-yellow-50\/60{background-color:var(--yellow-50)}@supports (color:color-mix(in lab, red, red)){.bg-yellow-50\/60{background-color:color-mix(in oklab,var(--yellow-50)60%,transparent)}}.bg-yellow-75{background-color:var(--yellow-75)}.bg-yellow-100,.bg-yellow-100\/70{background-color:var(--yellow-100)}@supports (color:color-mix(in lab, red, red)){.bg-yellow-100\/70{background-color:color-mix(in oklab,var(--yellow-100)70%,transparent)}}.bg-yellow-100\/80{background-color:var(--yellow-100)}@supports (color:color-mix(in lab, red, red)){.bg-yellow-100\/80{background-color:color-mix(in oklab,var(--yellow-100)80%,transparent)}}.bg-yellow-200{background-color:var(--yellow-200)}.bg-yellow-300{background-color:var(--yellow-300)}.bg-yellow-400,.bg-yellow-400\/40{background-color:var(--yellow-400)}@supports (color:color-mix(in lab, red, red)){.bg-yellow-400\/40{background-color:color-mix(in oklab,var(--yellow-400)40%,transparent)}}.bg-yellow-400\/60{background-color:var(--yellow-400)}@supports (color:color-mix(in lab, red, red)){.bg-yellow-400\/60{background-color:color-mix(in oklab,var(--yellow-400)60%,transparent)}}.bg-yellow-500,.bg-yellow-500\/10{background-color:var(--yellow-500)}@supports (color:color-mix(in lab, red, red)){.bg-yellow-500\/10{background-color:color-mix(in oklab,var(--yellow-500)10%,transparent)}}.bg-yellow-500\/15{background-color:var(--yellow-500)}@supports (color:color-mix(in lab, red, red)){.bg-yellow-500\/15{background-color:color-mix(in oklab,var(--yellow-500)15%,transparent)}}.bg-yellow-600{background-color:var(--yellow-600)}.bg-yellow-700,.bg-yellow-700\/15{background-color:var(--yellow-700)}@supports (color:color-mix(in lab, red, red)){.bg-yellow-700\/15{background-color:color-mix(in oklab,var(--yellow-700)15%,transparent)}}.bg-yellow-800{background-color:var(--yellow-800)}.bg-yellow-900{background-color:var(--yellow-900)}.bg-yellow-950{background-color:var(--yellow-950)}.bg-yellow-1000{background-color:var(--yellow-1000)}.bg-yellow-a25{background-color:var(--yellow-a25)}.bg-yellow-a50{background-color:var(--yellow-a50)}.bg-yellow-a75{background-color:var(--yellow-a75)}.bg-yellow-a100{background-color:var(--yellow-a100)}.bg-yellow-a200{background-color:var(--yellow-a200)}.bg-yellow-a300{background-color:var(--yellow-a300)}.bg-linear-45{--tw-gradient-position:45deg}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-45{--tw-gradient-position:45deg in oklab}}.bg-linear-45{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-b{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-b{--tw-gradient-position:to bottom in oklab}}.bg-linear-to-b{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-br{--tw-gradient-position:to bottom right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-br{--tw-gradient-position:to bottom right in oklab}}.bg-linear-to-br{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-l{--tw-gradient-position:to left}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-l{--tw-gradient-position:to left in oklab}}.bg-linear-to-l{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-r{--tw-gradient-position:to right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-r{--tw-gradient-position:to right in oklab}}.bg-linear-to-r{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-t{--tw-gradient-position:to top}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-t{--tw-gradient-position:to top in oklab}}.bg-linear-to-t{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-l{--tw-gradient-position:to left in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-t{--tw-gradient-position:to top in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.\[background-image\:linear-gradient\(to_top\,transparent\,var\(--bg-primary\)\)\,linear-gradient\(to_top\,transparent_var\(--single-line-fade-height\)\,var\(--bg-primary\)_var\(--single-line-fade-height\)\)\]{background-image:linear-gradient(to top,transparent,var(--bg-primary)),linear-gradient(to top,transparent var(--single-line-fade-height),var(--bg-primary)var(--single-line-fade-height))}.bg-\[linear-gradient\(90deg\,\#9E9DFF_0\%\,\#A6B8FF_48\%\,\#CFE6FF_100\%\)\]{background-image:linear-gradient(90deg,#9e9dff 0%,#a6b8ff 48%,#cfe6ff 100%)}.bg-\[linear-gradient\(180deg\,oklch\(1_0_0\/0\.3\)_80\%\,transparent_100\%\)\]{background-image:linear-gradient(#ffffff4d 80%,#0000 100%);background-image:linear-gradient(lab(100% 0 0/.3) 80%,#0000 100%)}.bg-\[linear-gradient\(180deg\,rgba\(0\,0\,0\,0\)_0\%\,rgba\(0\,0\,0\,0\.30\)_100\%\)\]{background-image:linear-gradient(#0000 0%,#0000004d 100%)}.bg-\[linear-gradient\(180deg\,rgba\(0\,0\,0\,0\.30\)_0\%\,rgba\(0\,0\,0\,0\)_100\%\)\]{background-image:linear-gradient(#0000004d 0%,#0000 100%)}.bg-\[linear-gradient\(180deg\,rgba\(33\,33\,33\,0\)_0\.12\%\,rgba\(33\,33\,33\,0\.85\)_51\.46\%\,\#212121_72\.79\%\)\]{background-image:linear-gradient(#21212100 .12%,#212121d9 51.46%,#212121 72.79%)}.bg-\[linear-gradient\(180deg\,rgba\(255\,255\,255\,0\)_24\.327\%\,\#FFFFFF_47\.029\%\)\]{background-image:linear-gradient(#fff0 24.327%,#fff 47.029%)}.bg-\[linear-gradient\(180deg\,rgba\(255\,255\,255\,0\.18\)\,rgba\(255\,255\,255\,0\.04\)\)\]{background-image:linear-gradient(#ffffff2e,#ffffff0a)}.bg-\[linear-gradient\(180deg\,var\(--bg-primary\)_0\%\,var\(--bg-primary\)_calc\(100\%-325px\)\,transparent_100\%\)\]{background-image:linear-gradient(180deg,var(--bg-primary)0%,var(--bg-primary)calc(100% - 325px),transparent 100%)}.bg-\[linear-gradient\(206\.72deg\,_\#EEEFFF_2\.34\%\,_\#FFFFFF_92\.37\%\)\]{background-image:linear-gradient(206.72deg,#eeefff 2.34%,#fff 92.37%)}.bg-\[radial-gradient\(125\%_70\%_at_50\%_0\%\,rgba\(105\,170\,255\,0\.24\)_0\%\,rgba\(62\,92\,156\,0\.18\)_32\%\,rgba\(18\,24\,37\,0\.92\)_58\%\,\#0f141f_100\%\)\]{background-image:radial-gradient(125% 70% at 50% 0,#69aaff3d 0%,#3e5c9c2e 32%,#121825eb 58%,#0f141f 100%)}.bg-\[radial-gradient\(circle\,_\#aaaaaa_0\.75px\,_transparent_0\.75px\)\]{background-image:radial-gradient(circle,#aaa .75px,#0000 .75px)}.bg-\[radial-gradient\(circle_at_30\%_30\%\,\#f9d423_0\%\,\#ff4e50_100\%\)\]{background-image:radial-gradient(circle at 30% 30%,#f9d423 0%,#ff4e50 100%)}.bg-\[radial-gradient\(ellipse_140\%_90\%_at_50\%_130\%\,var\(--bg-primary\)_0\%\,var\(--bg-primary\)_50\%\,transparent_90\%\)\]{background-image:radial-gradient(ellipse 140% 90% at 50% 130%,var(--bg-primary)0%,var(--bg-primary)50%,transparent 90%)}.bg-\[radial-gradient\(ellipse_at_center\,_\#4999E4_0\%\,_rgba\(0\,150\,230\,0\.3\)_35\%\,_rgba\(73\,153\,228\,0\)_70\%\)\]{background-image:radial-gradient(#4999e4 0%,#0096e64d 35%,#4999e400 70%)}.bg-\[repeating-linear-gradient\(-45deg\,transparent\,transparent_4px\,\#6b728066_4px\,\#6b728066_8px\)\]{background-image:repeating-linear-gradient(-45deg,#0000,#0000 4px,#6b728066 4px,#6b728066 8px)}.bg-\[url\(\"https\:\/\/cdn\.openai\.com\/ctf-cdn\/30bafe12-ad88-4ae0-8ed8-0045ffc1a17c\/form-bg\.jpg\"\)\]{background-image:url(https://cdn.openai.com/ctf-cdn/30bafe12-ad88-4ae0-8ed8-0045ffc1a17c/form-bg.jpg)}.bg-\[url\(https\:\/\/openaiassets\.blob\.core\.windows\.net\/\$web\/chatgpt\/clients\/noauth\/open_app_mobile_banner\.webp\)\]{background-image:url(https://openaiassets.blob.core.windows.net/$web/chatgpt/clients/noauth/open_app_mobile_banner.webp)}.bg-none{background-image:none}.bg-vert-light-gradient{background-image:linear-gradient(#fff0 13.94%,#fff 54.73%)}.\[--tw-gradient-position\:to_var\(--end\)\]{--tw-gradient-position:to var(--end)}.\[--tw-gradient-position\:to_var\(--start\)\]{--tw-gradient-position:to var(--start)}.from-\(--bg-primary\){--tw-gradient-from:var(--bg-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\(--bg-secondary\){--tw-gradient-from:var(--bg-secondary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[\#4c40ff\]{--tw-gradient-from:#4c40ff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[\#7a5cff\]{--tw-gradient-from:#7a5cff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[\#ED5EB4\]{--tw-gradient-from:#ed5eb4;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[\#F5FAFF\]{--tw-gradient-from:#f5faff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[\#F9F5FE\]{--tw-gradient-from:#f9f5fe;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[\#FFFBED\]{--tw-gradient-from:#fffbed;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[\#e8eaf9\]{--tw-gradient-from:#e8eaf9;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[var\(--bg-primary\)\]{--tw-gradient-from:var(--bg-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[var\(--bg-tertiary\)\]{--tw-gradient-from:var(--bg-tertiary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[var\(--canvas-bg\,var\(--bg-primary\)\)\]{--tw-gradient-from:var(--canvas-bg,var(--bg-primary));--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[var\(--main-surface-background\)\]{--tw-gradient-from:var(--main-surface-background);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-\[var\(--theme-user-msg-bg\)\]{--tw-gradient-from:var(--theme-user-msg-bg);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-black\/5{--tw-gradient-from:#0000000d;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.from-black\/5{--tw-gradient-from:lab(0% 0 0/.05)}}.from-black\/30{--tw-gradient-from:#0000004d;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.from-black\/30{--tw-gradient-from:lab(0% 0 0/.3)}}.from-black\/35{--tw-gradient-from:#00000059;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.from-black\/35{--tw-gradient-from:lab(0% 0 0/.35)}}.from-black\/50{--tw-gradient-from:#00000080;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.from-black\/50{--tw-gradient-from:lab(0% 0 0/.5)}}.from-black\/60{--tw-gradient-from:#0009;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.from-black\/60{--tw-gradient-from:lab(0% 0 0/.6)}}.from-black\/70{--tw-gradient-from:#000000b3;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.from-black\/70{--tw-gradient-from:lab(0% 0 0/.7)}}.from-black\/80{--tw-gradient-from:#000c;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.from-black\/80{--tw-gradient-from:lab(0% 0 0/.8)}}.from-black\/90{--tw-gradient-from:#000000e6;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.from-black\/90{--tw-gradient-from:lab(0% 0 0/.9)}}.from-gray-950\/90{--tw-gradient-from:var(--gray-950)}@supports (color:color-mix(in lab, red, red)){.from-gray-950\/90{--tw-gradient-from:color-mix(in oklab,var(--gray-950)90%,transparent)}}.from-gray-950\/90{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-purple-400{--tw-gradient-from:var(--purple-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-token-bg-elevated-secondary{--tw-gradient-from:var(--bg-elevated-secondary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-token-bg-primary{--tw-gradient-from:var(--bg-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-token-bg-primary\/0{--tw-gradient-from:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.from-token-bg-primary\/0{--tw-gradient-from:color-mix(in oklab,var(--bg-primary)0%,transparent)}}.from-token-bg-primary\/0{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-token-bg-tertiary\/30{--tw-gradient-from:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.from-token-bg-tertiary\/30{--tw-gradient-from:color-mix(in oklab,var(--bg-tertiary)30%,transparent)}}.from-token-bg-tertiary\/30{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-token-border-heavy{--tw-gradient-from:var(--border-heavy);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-token-interactive-bg-accent-default\/50{--tw-gradient-from:var(--interactive-bg-accent-default)}@supports (color:color-mix(in lab, red, red)){.from-token-interactive-bg-accent-default\/50{--tw-gradient-from:color-mix(in oklab,var(--interactive-bg-accent-default)50%,transparent)}}.from-token-interactive-bg-accent-default\/50{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-token-main-surface-primary{--tw-gradient-from:var(--main-surface-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-token-main-surface-secondary{--tw-gradient-from:var(--main-surface-secondary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-token-text-tertiary{--tw-gradient-from:var(--text-tertiary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-transparent{--tw-gradient-from:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-white{--tw-gradient-from:#fff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-white\/0{--tw-gradient-from:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.from-white\/0{--tw-gradient-from:lab(0% 0 0/0)}}.from-white\/15{--tw-gradient-from:#ffffff26;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.from-white\/15{--tw-gradient-from:lab(100% -.0000298023 .0000119209/.15)}}.from-white\/50{--tw-gradient-from:#ffffff80;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.from-white\/50{--tw-gradient-from:lab(100% -.0000298023 .0000119209/.5)}}.from-0\%{--tw-gradient-from-position:0%}.from-10\%{--tw-gradient-from-position:10%}.from-20\%{--tw-gradient-from-position:20%}.from-30\%{--tw-gradient-from-position:30%}.from-50\%{--tw-gradient-from-position:50%}.from-60\%{--tw-gradient-from-position:60%}.from-\[0\%\]{--tw-gradient-from-position:0%}.via-\[\#d8e4ff\]{--tw-gradient-via:#d8e4ff;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-\[\#fff8dd\]\/85{--tw-gradient-via:#fff8ddd9;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.via-\[\#fff8dd\]\/85{--tw-gradient-via:lab(97.6021% -.842184 13.9035/.85)}}.via-\[rgba\(255\,255\,255\,0\.8\)\]{--tw-gradient-via:#fffc;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-black\/0{--tw-gradient-via:transparent;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.via-black\/0{--tw-gradient-via:lab(0% 0 0/0)}}.via-black\/8{--tw-gradient-via:#00000014;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.via-black\/8{--tw-gradient-via:lab(0% 0 0/.08)}}.via-black\/10{--tw-gradient-via:#0000001a;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.via-black\/10{--tw-gradient-via:lab(0% 0 0/.1)}}.via-black\/20{--tw-gradient-via:#0003;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.via-black\/20{--tw-gradient-via:lab(0% 0 0/.2)}}.via-black\/40{--tw-gradient-via:#0006;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.via-black\/40{--tw-gradient-via:lab(0% 0 0/.4)}}.via-black\/60{--tw-gradient-via:#0009;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.via-black\/60{--tw-gradient-via:lab(0% 0 0/.6)}}.via-pink-500{--tw-gradient-via:var(--pink-500);--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-token-bg-primary{--tw-gradient-via:var(--bg-primary);--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-token-bg-primary\/0{--tw-gradient-via:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.via-token-bg-primary\/0{--tw-gradient-via:color-mix(in oklab,var(--bg-primary)0%,transparent)}}.via-token-bg-primary\/0{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-token-bg-primary\/60{--tw-gradient-via:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.via-token-bg-primary\/60{--tw-gradient-via:color-mix(in oklab,var(--bg-primary)60%,transparent)}}.via-token-bg-primary\/60{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-token-bg-primary\/70{--tw-gradient-via:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.via-token-bg-primary\/70{--tw-gradient-via:color-mix(in oklab,var(--bg-primary)70%,transparent)}}.via-token-bg-primary\/70{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-token-bg-primary\/80{--tw-gradient-via:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.via-token-bg-primary\/80{--tw-gradient-via:color-mix(in oklab,var(--bg-primary)80%,transparent)}}.via-token-bg-primary\/80{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-token-bg-primary\/85{--tw-gradient-via:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.via-token-bg-primary\/85{--tw-gradient-via:color-mix(in oklab,var(--bg-primary)85%,transparent)}}.via-token-bg-primary\/85{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-token-main-surface-primary\/80{--tw-gradient-via:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.via-token-main-surface-primary\/80{--tw-gradient-via:color-mix(in oklab,var(--main-surface-primary)80%,transparent)}}.via-token-main-surface-primary\/80{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-transparent{--tw-gradient-via:transparent;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-white\/5{--tw-gradient-via:#ffffff0d;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.via-white\/5{--tw-gradient-via:lab(100% -.0000298023 .0000119209/.05)}}.via-white\/10{--tw-gradient-via:#ffffff1a;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.via-white\/10{--tw-gradient-via:lab(100% -.0000298023 .0000119209/.1)}}.via-white\/40{--tw-gradient-via:#fff6;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.via-white\/40{--tw-gradient-via:lab(100% -.0000298023 .0000119209/.4)}}.via-white\/72{--tw-gradient-via:#ffffffb8;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.via-white\/72{--tw-gradient-via:lab(100% -.0000298023 .0000119209/.72)}}.via-30\%{--tw-gradient-via-position:30%}.via-40\%{--tw-gradient-via-position:40%}.via-50\%{--tw-gradient-via-position:50%}.to-\(--bg-primary\){--tw-gradient-to:var(--bg-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-\[\#6157ff\]{--tw-gradient-to:#6157ff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-\[\#8086F9\]{--tw-gradient-to:#8086f9;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-\[\#E5F3FF\]{--tw-gradient-to:#e5f3ff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-\[\#F9F5FE\]{--tw-gradient-to:#f9f5fe;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-\[\#FFF6D9\]{--tw-gradient-to:#fff6d9;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-\[\#a223ff\]{--tw-gradient-to:#a223ff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-\[\#f3d9ff\]{--tw-gradient-to:#f3d9ff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-\[var\(--bg-primary\)\]{--tw-gradient-to:var(--bg-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-black\/0{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.to-black\/0{--tw-gradient-to:lab(0% 0 0/0)}}.to-black\/10{--tw-gradient-to:#0000001a;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.to-black\/10{--tw-gradient-to:lab(0% 0 0/.1)}}.to-black\/25{--tw-gradient-to:#00000040;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.to-black\/25{--tw-gradient-to:lab(0% 0 0/.25)}}.to-black\/30{--tw-gradient-to:#0000004d;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.to-black\/30{--tw-gradient-to:lab(0% 0 0/.3)}}.to-black\/85{--tw-gradient-to:#000000d9;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.to-black\/85{--tw-gradient-to:lab(0% 0 0/.85)}}.to-blue-700\/80{--tw-gradient-to:var(--blue-700)}@supports (color:color-mix(in lab, red, red)){.to-blue-700\/80{--tw-gradient-to:color-mix(in oklab,var(--blue-700)80%,transparent)}}.to-blue-700\/80{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-gray-50{--tw-gradient-to:var(--gray-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-gray-500\/0{--tw-gradient-to:var(--gray-500)}@supports (color:color-mix(in lab, red, red)){.to-gray-500\/0{--tw-gradient-to:color-mix(in oklab,var(--gray-500)0%,transparent)}}.to-gray-500\/0{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-red-500{--tw-gradient-to:var(--red-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-token-bg-primary{--tw-gradient-to:var(--bg-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-token-bg-primary\/40{--tw-gradient-to:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.to-token-bg-primary\/40{--tw-gradient-to:color-mix(in oklab,var(--bg-primary)40%,transparent)}}.to-token-bg-primary\/40{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-token-bg-primary\/90{--tw-gradient-to:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.to-token-bg-primary\/90{--tw-gradient-to:color-mix(in oklab,var(--bg-primary)90%,transparent)}}.to-token-bg-primary\/90{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-token-bg-tertiary{--tw-gradient-to:var(--bg-tertiary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white{--tw-gradient-to:#fff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white\/20{--tw-gradient-to:#fff3;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.to-white\/20{--tw-gradient-to:lab(100% -.0000298023 .0000119209/.2)}}.to-white\/35{--tw-gradient-to:#ffffff59;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.to-white\/35{--tw-gradient-to:lab(100% -.0000298023 .0000119209/.35)}}.to-white\/78{--tw-gradient-to:#ffffffc7;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.to-white\/78{--tw-gradient-to:lab(100% -.0000298023 .0000119209/.78)}}.to-white\/95{--tw-gradient-to:#fffffff2;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.to-white\/95{--tw-gradient-to:lab(100% -.0000298023 .0000119209/.95)}}.to-white\/96{--tw-gradient-to:#fffffff5;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.to-white\/96{--tw-gradient-to:lab(100% -.0000298023 .0000119209/.96)}}.to-25\%{--tw-gradient-to-position:25%}.to-100\%{--tw-gradient-to-position:100%}.to-\[30\%\]{--tw-gradient-to-position:30%}.mask-shimmer-muted{animation-name:mask-shimmer-offset-move;animation-duration:var(--tw-mask-shimmer-duration,4s);animation-iteration-count:infinite;animation-delay:var(--tw-mask-shimmer-delay,0s);-webkit-mask-image:linear-gradient(to right,#fff7,#fff7 calc(var(--mask-shimmer-offset) - 10%),white var(--mask-shimmer-offset),#fff7 calc(var(--mask-shimmer-offset) + 10%),#fff7 100%);-webkit-mask-image:linear-gradient(to right,#fff7,#fff7 calc(var(--mask-shimmer-offset) - 10%),white var(--mask-shimmer-offset),#fff7 calc(var(--mask-shimmer-offset) + 10%),#fff7 100%);-webkit-mask-image:linear-gradient(to right,#fff7,#fff7 calc(var(--mask-shimmer-offset) - 10%),white var(--mask-shimmer-offset),#fff7 calc(var(--mask-shimmer-offset) + 10%),#fff7 100%);-webkit-mask-image:linear-gradient(to right,#fff7,#fff7 calc(var(--mask-shimmer-offset) - 10%),white var(--mask-shimmer-offset),#fff7 calc(var(--mask-shimmer-offset) + 10%),#fff7 100%);mask-image:linear-gradient(to right,#fff7,#fff7 calc(var(--mask-shimmer-offset) - 10%),white var(--mask-shimmer-offset),#fff7 calc(var(--mask-shimmer-offset) + 10%),#fff7 100%);-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:200% 200%;mask-size:200% 200%}.\[mask-image\:linear-gradient\(black\,transparent_80\%\)\]{-webkit-mask-image:linear-gradient(#000,#0000 80%);mask-image:linear-gradient(#000,#0000 80%)}.\[mask-image\:linear-gradient\(to_bottom\,black_80\%\,transparent_100\%\)\]{-webkit-mask-image:linear-gradient(#000 80%,#0000 100%);mask-image:linear-gradient(#000 80%,#0000 100%)}.\[mask-image\:linear-gradient\(to_bottom\,black_calc\(100\%_-_2rem\)\,transparent_100\%\)\]{-webkit-mask-image:linear-gradient(#000 calc(100% - 2rem),#0000 100%);mask-image:linear-gradient(#000 calc(100% - 2rem),#0000 100%)}.\[mask-image\:linear-gradient\(to_bottom\,rgba\(0\,0\,0\,1\)_20\%\,rgba\(0\,0\,0\,0\)_100\%\)\]{-webkit-mask-image:linear-gradient(#000 20%,#0000 100%);mask-image:linear-gradient(#000 20%,#0000 100%)}.\[mask-image\:linear-gradient\(to_right\,black_33\%\,transparent_66\%\)\]{-webkit-mask-image:linear-gradient(90deg,#000 33%,#0000 66%);mask-image:linear-gradient(90deg,#000 33%,#0000 66%)}.\[mask-image\:linear-gradient\(to_right\,black_85\%\,transparent_100\%\)\]{-webkit-mask-image:linear-gradient(90deg,#000 85%,#0000 100%);mask-image:linear-gradient(90deg,#000 85%,#0000 100%)}.\[mask-image\:linear-gradient\(to_right\,black_calc\(100\%_-_1rem\)\,transparent_100\%\)\]{-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - 1rem),#0000 100%);mask-image:linear-gradient(90deg,#000 calc(100% - 1rem),#0000 100%)}.\[mask-image\:linear-gradient\(to_right\,black_calc\(100\%_-_2rem\)\,transparent_100\%\)\]{-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - 2rem),#0000 100%);mask-image:linear-gradient(90deg,#000 calc(100% - 2rem),#0000 100%)}.\[mask-image\:linear-gradient\(to_right\,transparent_0\%\,black_20\%\,black_80\%\,transparent_100\%\)\,linear-gradient\(to_top\,black_0\%\,black_75\%\,transparent_100\%\)\]{-webkit-mask-image:linear-gradient(90deg,#0000 0%,#000 20%,#000 80%,#0000 100%),linear-gradient(#0000 0%,#000 25%,#000 100%);mask-image:linear-gradient(90deg,#0000 0%,#000 20%,#000 80%,#0000 100%),linear-gradient(#0000 0%,#000 25%,#000 100%)}.\[mask-image\:linear-gradient\(to_top\,black\,transparent\)\]{-webkit-mask-image:linear-gradient(#0000,#000);mask-image:linear-gradient(#0000,#000)}.\[mask-image\:linear-gradient\(to_top_left\,rgba\(0\,0\,0\,0\)_0\%\,rgba\(0\,0\,0\,1\)_30\%\,rgba\(0\,0\,0\,1\)_70\%\,rgba\(0\,0\,0\,0\)_100\%\)\]{-webkit-mask-image:linear-gradient(to top left,#0000 0%,#000 30%,#000 70%,#0000 100%);mask-image:linear-gradient(to top left,#0000 0%,#000 30%,#000 70%,#0000 100%)}.mask-\[linear-gradient\(180deg\,transparent_33\.62\%\,black_100\%\)\]{-webkit-mask-image:linear-gradient(#0000 33.62%,#000 100%);mask-image:linear-gradient(#0000 33.62%,#000 100%)}.\[background-size\:24px_16px\]{background-size:24px 16px}.\[background-size\:100\%_150\%\]{background-size:100% 150%}.\[background-size\:100\%_var\(--single-line-fade-height\)\,100\%_100\%\]{background-size:100% var(--single-line-fade-height),100% 100%}.bg-\[length\:12px_12px\]{background-size:12px 12px}.bg-auto{background-size:auto}.bg-contain{background-size:contain}.bg-cover{background-size:cover}.bg-clip-border{background-clip:border-box}.bg-clip-padding{background-clip:padding-box}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.\[background-position\:bottom\,top\]{background-position:bottom,top}.\[background-position\:theme\(spacing\.3\)_50\%\]{background-position:.75rem}.bg-center{background-position:50%}.bg-top{background-position:top}.bg-no-repeat{background-repeat:no-repeat}.bg-repeat{background-repeat:repeat}.\[mask-composite\:intersect\]{-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}.\[mask-size\:100\%_100\%\]{-webkit-mask-size:100% 100%;mask-size:100% 100%}.\[mask-size\:300\%_100\%\]{-webkit-mask-size:300% 100%;mask-size:300% 100%}.\[mask-position\:100\%_0\%\]{-webkit-mask-position:100% 0;mask-position:100% 0}.\[mask-repeat\:no-repeat\]{-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.fill-\[\#5856D6\]{fill:#5856d6}.fill-\[\#a96e25\]{fill:#a96e25}.fill-\[var\(--text-primary\)\]{fill:var(--text-primary)}.fill-black{fill:#000}.fill-current{fill:currentColor}.fill-gray-900{fill:var(--gray-900)}.fill-green-500{fill:var(--green-500)}.fill-token-icon-accent{fill:var(--icon-accent)}.fill-token-main-surface-primary{fill:var(--main-surface-primary)}.fill-token-text-primary{fill:var(--text-primary)}.fill-token-text-secondary{fill:var(--text-secondary)}.fill-transparent{fill:#0000}.fill-white{fill:#fff}.fill-yellow-500{fill:var(--yellow-500)}.stroke-\[rgba\(0\,0\,0\,0\)\]{stroke:#0000}.stroke-\[rgba\(0\,0\,0\,0\.1\)\]{stroke:#0000001a}.stroke-\[rgba\(0\,0\,0\,0\.32\)\]{stroke:#00000052}.stroke-\[rgba\(255\,255\,255\,0\.24\)\]{stroke:#ffffff3d}.stroke-black\/10{stroke:#0000001a;stroke:lab(0% 0 0/.1)}.stroke-black\/20{stroke:#0003;stroke:lab(0% 0 0/.2)}.stroke-brand-purple\/25{stroke:#ab68ff40;stroke:lab(57.1209% 49.4506 -66.2104/.25)}.stroke-current{stroke:currentColor}.stroke-gray-400{stroke:var(--gray-400)}.stroke-token-border-light{stroke:var(--border-light)}.stroke-token-main-surface-tertiary{stroke:var(--main-surface-tertiary)}.stroke-token-text-primary\/20{stroke:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.stroke-token-text-primary\/20{stroke:color-mix(in oklab,var(--text-primary)20%,transparent)}}.stroke-white{stroke:#fff}.stroke-white\/20{stroke:#fff3;stroke:lab(100% -.0000298023 .0000119209/.2)}.stroke-0{stroke-width:0}.stroke-2{stroke-width:2px}.stroke-3{stroke-width:3px}.stroke-4{stroke-width:4px}.stroke-\[0\.75\]{stroke-width:.75px}.stroke-\[1\.25\]{stroke-width:1.25px}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-fill{object-fit:fill}.object-scale-down{object-fit:scale-down}.object-\[0\%_25\%\]{object-position:0% 25%}.object-\[50\%_120\%\]{object-position:50% 120%}.object-bottom{object-position:bottom}.object-center{object-position:center}.object-right{object-position:right}.object-start{object-position:var(--start)}.object-top{object-position:top}.\!p-0{padding:calc(var(--spacing)*0)!important}.\!p-2{padding:calc(var(--spacing)*2)!important}.p-0{padding:calc(var(--spacing)*0)}.p-0\!{padding:calc(var(--spacing)*0)!important}.p-0\.5{padding:calc(var(--spacing)*.5)}.p-0\.25{padding:calc(var(--spacing)*.25)}.p-0\.75{padding:calc(var(--spacing)*.75)}.p-1{padding:calc(var(--spacing)*1)}.p-1\!{padding:calc(var(--spacing)*1)!important}.p-1\.5{padding:calc(var(--spacing)*1.5)}.p-1\.25{padding:calc(var(--spacing)*1.25)}.p-2{padding:calc(var(--spacing)*2)}.p-2\!{padding:calc(var(--spacing)*2)!important}.p-2\.5{padding:calc(var(--spacing)*2.5)}.p-3{padding:calc(var(--spacing)*3)}.p-3\.5{padding:calc(var(--spacing)*3.5)}.p-4{padding:calc(var(--spacing)*4)}.p-4\!{padding:calc(var(--spacing)*4)!important}.p-5{padding:calc(var(--spacing)*5)}.p-6{padding:calc(var(--spacing)*6)}.p-6\!{padding:calc(var(--spacing)*6)!important}.p-7{padding:calc(var(--spacing)*7)}.p-8{padding:calc(var(--spacing)*8)}.p-9{padding:calc(var(--spacing)*9)}.p-10{padding:calc(var(--spacing)*10)}.p-12{padding:calc(var(--spacing)*12)}.p-14{padding:calc(var(--spacing)*14)}.p-24{padding:calc(var(--spacing)*24)}.p-\[0\.5px\]{padding:.5px}.p-\[1px\]{padding:1px}.p-\[2px\]{padding:2px}.p-\[3px\]{padding:3px}.p-\[4px\]{padding:4px}.p-\[5px\]{padding:5px}.p-\[6px\]{padding:6px}.p-\[8rem\]{padding:8rem}.p-\[11\.25px\]{padding:11.25px}.p-\[14px\]{padding:14px}.p-\[16px\]{padding:16px}.p-\[18px\]{padding:18px}.p-\[20px_20dvw\]{padding:20px 20dvw}.p-\[20vw\]{padding:20vw}.p-\[22px\]{padding:22px}.p-snc-1{padding:var(--snc-1)}.\!px-0{padding-inline:calc(var(--spacing)*0)!important}.\!px-6{padding-inline:calc(var(--spacing)*6)!important}.px-\(--build-shell-inline-padding\){padding-inline:var(--build-shell-inline-padding)}.px-\(--images-app-padding\){padding-inline:var(--images-app-padding)}.px-\(--thread-content-margin\){padding-inline:var(--thread-content-margin)}.px-0{padding-inline:calc(var(--spacing)*0)}.px-0\!{padding-inline:calc(var(--spacing)*0)!important}.px-0\.5{padding-inline:calc(var(--spacing)*.5)}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\!{padding-inline:calc(var(--spacing)*1)!important}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-1\.25{padding-inline:calc(var(--spacing)*1.25)}.px-1\.75\!{padding-inline:calc(var(--spacing)*1.75)!important}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\!{padding-inline:calc(var(--spacing)*2)!important}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-2\.25{padding-inline:calc(var(--spacing)*2.25)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-3\.5{padding-inline:calc(var(--spacing)*3.5)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-4\!{padding-inline:calc(var(--spacing)*4)!important}.px-4\.5{padding-inline:calc(var(--spacing)*4.5)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-5\!{padding-inline:calc(var(--spacing)*5)!important}.px-5\.5{padding-inline:calc(var(--spacing)*5.5)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-6\!{padding-inline:calc(var(--spacing)*6)!important}.px-7{padding-inline:calc(var(--spacing)*7)}.px-8{padding-inline:calc(var(--spacing)*8)}.px-8\!{padding-inline:calc(var(--spacing)*8)!important}.px-9{padding-inline:calc(var(--spacing)*9)}.px-10{padding-inline:calc(var(--spacing)*10)}.px-10\!{padding-inline:calc(var(--spacing)*10)!important}.px-12{padding-inline:calc(var(--spacing)*12)}.px-16{padding-inline:calc(var(--spacing)*16)}.px-20{padding-inline:calc(var(--spacing)*20)}.px-36{padding-inline:calc(var(--spacing)*36)}.px-\[0\.375rem\]{padding-left:.375rem;padding-right:.375rem}.px-\[1\.5px\]\!{padding-left:1.5px!important;padding-right:1.5px!important}.px-\[1rem\]{padding-left:1rem;padding-right:1rem}.px-\[2px\]{padding-left:2px;padding-right:2px}.px-\[5\.5px\]{padding-left:5.5px;padding-right:5.5px}.px-\[5px\]{padding-left:5px;padding-right:5px}.px-\[6px\]{padding-left:6px;padding-right:6px}.px-\[10px\]{padding-left:10px;padding-right:10px}.px-\[14px\]{padding-left:14px;padding-right:14px}.px-\[15px\]{padding-left:15px;padding-right:15px}.px-\[16px\]{padding-left:16px;padding-right:16px}.px-\[18px\]{padding-left:18px;padding-right:18px}.px-\[19px\]{padding-left:19px;padding-right:19px}.px-\[22px\]{padding-left:22px;padding-right:22px}.px-\[var\(--padding\)\]{padding-inline:var(--padding)}.px-px{padding-left:1px;padding-right:1px}.px-snc-1{padding-inline:var(--snc-1)}.px-snc-2{padding-inline:var(--snc-2)}.px-snc-results-padding{padding-inline:var(--snc-results-padding)}.\!py-0{padding-block:calc(var(--spacing)*0)!important}.py-0{padding-block:calc(var(--spacing)*0)}.py-0\!{padding-block:calc(var(--spacing)*0)!important}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-0\.75{padding-block:calc(var(--spacing)*.75)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\!{padding-block:calc(var(--spacing)*1)!important}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\!{padding-block:calc(var(--spacing)*2)!important}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-2\.25{padding-block:calc(var(--spacing)*2.25)}.py-3{padding-block:calc(var(--spacing)*3)}.py-3\!{padding-block:calc(var(--spacing)*3)!important}.py-3\.5{padding-block:calc(var(--spacing)*3.5)}.py-4{padding-block:calc(var(--spacing)*4)}.py-4\!{padding-block:calc(var(--spacing)*4)!important}.py-5{padding-block:calc(var(--spacing)*5)}.py-6{padding-block:calc(var(--spacing)*6)}.py-6\.5{padding-block:calc(var(--spacing)*6.5)}.py-7{padding-block:calc(var(--spacing)*7)}.py-8{padding-block:calc(var(--spacing)*8)}.py-10{padding-block:calc(var(--spacing)*10)}.py-12{padding-block:calc(var(--spacing)*12)}.py-14{padding-block:calc(var(--spacing)*14)}.py-15{padding-block:calc(var(--spacing)*15)}.py-16{padding-block:calc(var(--spacing)*16)}.py-17{padding-block:calc(var(--spacing)*17)}.py-20{padding-block:calc(var(--spacing)*20)}.py-32{padding-block:calc(var(--spacing)*32)}.py-48{padding-block:calc(var(--spacing)*48)}.py-\[0\.2rem\]{padding-top:.2rem;padding-bottom:.2rem}.py-\[0\.108em\]{padding-top:.108em;padding-bottom:.108em}.py-\[1px\]{padding-top:1px;padding-bottom:1px}.py-\[2px\]{padding-top:2px;padding-bottom:2px}.py-\[4px\]{padding-top:4px;padding-bottom:4px}.py-\[5px\]{padding-top:5px;padding-bottom:5px}.py-\[10px\]{padding-top:10px;padding-bottom:10px}.py-\[12px\]{padding-top:12px;padding-bottom:12px}.py-\[13px\]{padding-top:13px;padding-bottom:13px}.py-\[14px\]{padding-top:14px;padding-bottom:14px}.py-\[15px\]{padding-top:15px;padding-bottom:15px}.py-\[18px\]{padding-top:18px;padding-bottom:18px}.py-\[25\%\]{padding-top:25%;padding-bottom:25%}.py-\[28px\]{padding-top:28px;padding-bottom:28px}.py-snc-1{padding-block:var(--snc-1)}.ps-\(--writing-block-editor-pl\):dir(ltr){padding-left:var(--writing-block-editor-pl)}.ps-\(--writing-block-editor-pl\):dir(rtl){padding-right:var(--writing-block-editor-pl)}.ps-0:dir(ltr){padding-left:calc(var(--spacing)*0)}.ps-0:dir(rtl){padding-right:calc(var(--spacing)*0)}.ps-0\!:dir(ltr){padding-left:calc(var(--spacing)*0)}.ps-0\!:dir(rtl){padding-right:calc(var(--spacing)*0)}.ps-0\.5:dir(ltr){padding-left:calc(var(--spacing)*.5)}.ps-0\.5:dir(rtl){padding-right:calc(var(--spacing)*.5)}.ps-1:dir(ltr){padding-left:calc(var(--spacing)*1)}.ps-1:dir(rtl){padding-right:calc(var(--spacing)*1)}.ps-1\.5:dir(ltr){padding-left:calc(var(--spacing)*1.5)}.ps-1\.5:dir(rtl){padding-right:calc(var(--spacing)*1.5)}.ps-2:dir(ltr){padding-left:calc(var(--spacing)*2)}.ps-2:dir(rtl){padding-right:calc(var(--spacing)*2)}.ps-2\!:dir(ltr){padding-left:calc(var(--spacing)*2)}.ps-2\!:dir(rtl){padding-right:calc(var(--spacing)*2)}.ps-2\.5:dir(ltr){padding-left:calc(var(--spacing)*2.5)}.ps-2\.5:dir(rtl){padding-right:calc(var(--spacing)*2.5)}.ps-2\.5\!:dir(ltr){padding-left:calc(var(--spacing)*2.5)}.ps-2\.5\!:dir(rtl){padding-right:calc(var(--spacing)*2.5)}.ps-3:dir(ltr){padding-left:calc(var(--spacing)*3)}.ps-3:dir(rtl){padding-right:calc(var(--spacing)*3)}.ps-3\!:dir(ltr){padding-left:calc(var(--spacing)*3)}.ps-3\!:dir(rtl){padding-right:calc(var(--spacing)*3)}.ps-3\.5:dir(ltr){padding-left:calc(var(--spacing)*3.5)}.ps-3\.5:dir(rtl){padding-right:calc(var(--spacing)*3.5)}.ps-4:dir(ltr){padding-left:calc(var(--spacing)*4)}.ps-4:dir(rtl){padding-right:calc(var(--spacing)*4)}.ps-4\!:dir(ltr){padding-left:calc(var(--spacing)*4)}.ps-4\!:dir(rtl){padding-right:calc(var(--spacing)*4)}.ps-4\.5:dir(ltr){padding-left:calc(var(--spacing)*4.5)}.ps-4\.5:dir(rtl){padding-right:calc(var(--spacing)*4.5)}.ps-5:dir(ltr){padding-left:calc(var(--spacing)*5)}.ps-5:dir(rtl){padding-right:calc(var(--spacing)*5)}.ps-6:dir(ltr){padding-left:calc(var(--spacing)*6)}.ps-6:dir(rtl){padding-right:calc(var(--spacing)*6)}.ps-6\!:dir(ltr){padding-left:calc(var(--spacing)*6)}.ps-6\!:dir(rtl){padding-right:calc(var(--spacing)*6)}.ps-7:dir(ltr){padding-left:calc(var(--spacing)*7)}.ps-7:dir(rtl){padding-right:calc(var(--spacing)*7)}.ps-8:dir(ltr){padding-left:calc(var(--spacing)*8)}.ps-8:dir(rtl){padding-right:calc(var(--spacing)*8)}.ps-9:dir(ltr){padding-left:calc(var(--spacing)*9)}.ps-9:dir(rtl){padding-right:calc(var(--spacing)*9)}.ps-10:dir(ltr){padding-left:calc(var(--spacing)*10)}.ps-10:dir(rtl){padding-right:calc(var(--spacing)*10)}.ps-10\.5:dir(ltr){padding-left:calc(var(--spacing)*10.5)}.ps-10\.5:dir(rtl){padding-right:calc(var(--spacing)*10.5)}.ps-11:dir(ltr){padding-left:calc(var(--spacing)*11)}.ps-11:dir(rtl){padding-right:calc(var(--spacing)*11)}.ps-12:dir(ltr){padding-left:calc(var(--spacing)*12)}.ps-12:dir(rtl){padding-right:calc(var(--spacing)*12)}.ps-\[1px\]:dir(ltr){padding-left:1px}.ps-\[1px\]:dir(rtl){padding-right:1px}.ps-\[3\.25rem\]:dir(ltr){padding-left:3.25rem}.ps-\[3\.25rem\]:dir(rtl){padding-right:3.25rem}.ps-\[3px\]:dir(ltr){padding-left:3px}.ps-\[3px\]:dir(rtl){padding-right:3px}.ps-\[10px\]:dir(ltr){padding-left:10px}.ps-\[10px\]:dir(rtl){padding-right:10px}.ps-\[14px\]:dir(ltr){padding-left:14px}.ps-\[14px\]:dir(rtl){padding-right:14px}.ps-\[20px\]:dir(ltr){padding-left:20px}.ps-\[20px\]:dir(rtl){padding-right:20px}.ps-\[22px\]:dir(ltr){padding-left:22px}.ps-\[22px\]:dir(rtl){padding-right:22px}.pe-\(--writing-block-editor-pr\):dir(ltr){padding-right:var(--writing-block-editor-pr)}.pe-\(--writing-block-editor-pr\):dir(rtl){padding-left:var(--writing-block-editor-pr)}.pe-0:dir(ltr){padding-right:calc(var(--spacing)*0)}.pe-0:dir(rtl){padding-left:calc(var(--spacing)*0)}.pe-0\.5:dir(ltr){padding-right:calc(var(--spacing)*.5)}.pe-0\.5:dir(rtl){padding-left:calc(var(--spacing)*.5)}.pe-1:dir(ltr){padding-right:calc(var(--spacing)*1)}.pe-1:dir(rtl){padding-left:calc(var(--spacing)*1)}.pe-1\!:dir(ltr){padding-right:calc(var(--spacing)*1)}.pe-1\!:dir(rtl){padding-left:calc(var(--spacing)*1)}.pe-1\.5:dir(ltr){padding-right:calc(var(--spacing)*1.5)}.pe-1\.5:dir(rtl){padding-left:calc(var(--spacing)*1.5)}.pe-2:dir(ltr){padding-right:calc(var(--spacing)*2)}.pe-2:dir(rtl){padding-left:calc(var(--spacing)*2)}.pe-2\!:dir(ltr){padding-right:calc(var(--spacing)*2)}.pe-2\!:dir(rtl){padding-left:calc(var(--spacing)*2)}.pe-2\.5:dir(ltr){padding-right:calc(var(--spacing)*2.5)}.pe-2\.5:dir(rtl){padding-left:calc(var(--spacing)*2.5)}.pe-2\.5\!:dir(ltr){padding-right:calc(var(--spacing)*2.5)}.pe-2\.5\!:dir(rtl){padding-left:calc(var(--spacing)*2.5)}.pe-3:dir(ltr){padding-right:calc(var(--spacing)*3)}.pe-3:dir(rtl){padding-left:calc(var(--spacing)*3)}.pe-3\!:dir(ltr){padding-right:calc(var(--spacing)*3)}.pe-3\!:dir(rtl){padding-left:calc(var(--spacing)*3)}.pe-3\.5:dir(ltr){padding-right:calc(var(--spacing)*3.5)}.pe-3\.5:dir(rtl){padding-left:calc(var(--spacing)*3.5)}.pe-4:dir(ltr){padding-right:calc(var(--spacing)*4)}.pe-4:dir(rtl){padding-left:calc(var(--spacing)*4)}.pe-4\.5:dir(ltr){padding-right:calc(var(--spacing)*4.5)}.pe-4\.5:dir(rtl){padding-left:calc(var(--spacing)*4.5)}.pe-5:dir(ltr){padding-right:calc(var(--spacing)*5)}.pe-5:dir(rtl){padding-left:calc(var(--spacing)*5)}.pe-5\!:dir(ltr){padding-right:calc(var(--spacing)*5)}.pe-5\!:dir(rtl){padding-left:calc(var(--spacing)*5)}.pe-6:dir(ltr){padding-right:calc(var(--spacing)*6)}.pe-6:dir(rtl){padding-left:calc(var(--spacing)*6)}.pe-7:dir(ltr){padding-right:calc(var(--spacing)*7)}.pe-7:dir(rtl){padding-left:calc(var(--spacing)*7)}.pe-8:dir(ltr){padding-right:calc(var(--spacing)*8)}.pe-8:dir(rtl){padding-left:calc(var(--spacing)*8)}.pe-9:dir(ltr){padding-right:calc(var(--spacing)*9)}.pe-9:dir(rtl){padding-left:calc(var(--spacing)*9)}.pe-9\!:dir(ltr){padding-right:calc(var(--spacing)*9)}.pe-9\!:dir(rtl){padding-left:calc(var(--spacing)*9)}.pe-10:dir(ltr){padding-right:calc(var(--spacing)*10)}.pe-10:dir(rtl){padding-left:calc(var(--spacing)*10)}.pe-11:dir(ltr){padding-right:calc(var(--spacing)*11)}.pe-11:dir(rtl){padding-left:calc(var(--spacing)*11)}.pe-12:dir(ltr){padding-right:calc(var(--spacing)*12)}.pe-12:dir(rtl){padding-left:calc(var(--spacing)*12)}.pe-14:dir(ltr){padding-right:calc(var(--spacing)*14)}.pe-14:dir(rtl){padding-left:calc(var(--spacing)*14)}.pe-16:dir(ltr){padding-right:calc(var(--spacing)*16)}.pe-16:dir(rtl){padding-left:calc(var(--spacing)*16)}.pe-20:dir(ltr){padding-right:calc(var(--spacing)*20)}.pe-20:dir(rtl){padding-left:calc(var(--spacing)*20)}.pe-24:dir(ltr){padding-right:calc(var(--spacing)*24)}.pe-24:dir(rtl){padding-left:calc(var(--spacing)*24)}.pe-36:dir(ltr){padding-right:calc(var(--spacing)*36)}.pe-36:dir(rtl){padding-left:calc(var(--spacing)*36)}.pe-44:dir(ltr){padding-right:calc(var(--spacing)*44)}.pe-44:dir(rtl){padding-left:calc(var(--spacing)*44)}.pe-\[2px\]:dir(ltr){padding-right:2px}.pe-\[2px\]:dir(rtl){padding-left:2px}.pe-\[10px\]:dir(ltr){padding-right:10px}.pe-\[10px\]:dir(rtl){padding-left:10px}.pe-\[40px\]:dir(ltr){padding-right:40px}.pe-\[40px\]:dir(rtl){padding-left:40px}.\!pt-2{padding-top:calc(var(--spacing)*2)!important}.\!pt-4{padding-top:calc(var(--spacing)*4)!important}.\!pt-6{padding-top:calc(var(--spacing)*6)!important}.pt-\(--sidebar-section-first-margin-top\){padding-top:var(--sidebar-section-first-margin-top)}.pt-\(--sidebar-section-margin-top\){padding-top:var(--sidebar-section-margin-top)}.pt-\(--writing-block-editor-pt\){padding-top:var(--writing-block-editor-pt)}.pt-0{padding-top:calc(var(--spacing)*0)}.pt-0\.5{padding-top:calc(var(--spacing)*.5)}.pt-0\.25{padding-top:calc(var(--spacing)*.25)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-1\!{padding-top:calc(var(--spacing)*1)!important}.pt-1\.5{padding-top:calc(var(--spacing)*1.5)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-2\.5{padding-top:calc(var(--spacing)*2.5)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-3\!{padding-top:calc(var(--spacing)*3)!important}.pt-3\.5{padding-top:calc(var(--spacing)*3.5)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-4\.5{padding-top:calc(var(--spacing)*4.5)}.pt-5{padding-top:calc(var(--spacing)*5)}.pt-5\!{padding-top:calc(var(--spacing)*5)!important}.pt-6{padding-top:calc(var(--spacing)*6)}.pt-7{padding-top:calc(var(--spacing)*7)}.pt-8{padding-top:calc(var(--spacing)*8)}.pt-9{padding-top:calc(var(--spacing)*9)}.pt-10{padding-top:calc(var(--spacing)*10)}.pt-12{padding-top:calc(var(--spacing)*12)}.pt-14{padding-top:calc(var(--spacing)*14)}.pt-16{padding-top:calc(var(--spacing)*16)}.pt-17{padding-top:calc(var(--spacing)*17)}.pt-20{padding-top:calc(var(--spacing)*20)}.pt-24{padding-top:calc(var(--spacing)*24)}.pt-33{padding-top:calc(var(--spacing)*33)}.pt-\[0\.125rem\]{padding-top:.125rem}.pt-\[0px\]{padding-top:0}.pt-\[2px\]{padding-top:2px}.pt-\[3px\]{padding-top:3px}.pt-\[4\.5px\]{padding-top:4.5px}.pt-\[4px\]{padding-top:4px}.pt-\[11px\]{padding-top:11px}.pt-\[13px\]{padding-top:13px}.pt-\[15vh\]{padding-top:15vh}.pt-\[17px\]{padding-top:17px}.pt-\[18px\]{padding-top:18px}.pt-\[56px\]{padding-top:56px}.pt-\[71px\]{padding-top:71px}.pt-\[72px\]{padding-top:72px}.pt-\[86px\]{padding-top:86px}.pt-\[120px\]{padding-top:120px}.pt-\[246px\]{padding-top:246px}.pt-\[calc\(0\.75rem\+env\(safe-area-inset-top\)\)\]{padding-top:calc(.75rem + env(safe-area-inset-top))}.pt-\[calc\(1\.75rem\+env\(safe-area-inset-top\,0px\)\)\]{padding-top:calc(1.75rem + env(safe-area-inset-top,0px))}.pt-\[calc\(env\(safe-area-inset-bottom\,0px\)\/2\)\]{padding-top:calc(env(safe-area-inset-bottom,0px)/2)}.pt-\[calc\(env\(safe-area-inset-top\,0px\)\+1rem\)\]{padding-top:calc(env(safe-area-inset-top,0px) + 1rem)}.pt-mkt-header-height{padding-top:var(--mkt-header-height)}.pt-px{padding-top:1px}.pr-3{padding-right:calc(var(--spacing)*3)}.pr-4{padding-right:calc(var(--spacing)*4)}.pr-8{padding-right:calc(var(--spacing)*8)}.pr-10{padding-right:calc(var(--spacing)*10)}.\!pb-0{padding-bottom:calc(var(--spacing)*0)!important}.\!pb-1\.5{padding-bottom:calc(var(--spacing)*1.5)!important}.\!pb-3{padding-bottom:calc(var(--spacing)*3)!important}.\!pb-5{padding-bottom:calc(var(--spacing)*5)!important}.\!pb-6{padding-bottom:calc(var(--spacing)*6)!important}.\!pb-\[24px\]{padding-bottom:24px!important}.\!pb-\[88px\]{padding-bottom:88px!important}.pb-\(--composer-height\,28px\){padding-bottom:var(--composer-height,28px)}.pb-\(--thread-component-gap\,24px\){padding-bottom:var(--thread-component-gap,24px)}.pb-\(--writing-block-editor-pb\){padding-bottom:var(--writing-block-editor-pb)}.pb-0{padding-bottom:calc(var(--spacing)*0)}.pb-0\.5{padding-bottom:calc(var(--spacing)*.5)}.pb-1{padding-bottom:calc(var(--spacing)*1)}.pb-1\.5{padding-bottom:calc(var(--spacing)*1.5)}.pb-1\.25{padding-bottom:calc(var(--spacing)*1.25)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-2\!{padding-bottom:calc(var(--spacing)*2)!important}.pb-2\.5{padding-bottom:calc(var(--spacing)*2.5)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-3\.5{padding-bottom:calc(var(--spacing)*3.5)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-4\.5{padding-bottom:calc(var(--spacing)*4.5)}.pb-5{padding-bottom:calc(var(--spacing)*5)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pb-6\!{padding-bottom:calc(var(--spacing)*6)!important}.pb-7{padding-bottom:calc(var(--spacing)*7)}.pb-8{padding-bottom:calc(var(--spacing)*8)}.pb-9{padding-bottom:calc(var(--spacing)*9)}.pb-9\!{padding-bottom:calc(var(--spacing)*9)!important}.pb-10{padding-bottom:calc(var(--spacing)*10)}.pb-12{padding-bottom:calc(var(--spacing)*12)}.pb-13{padding-bottom:calc(var(--spacing)*13)}.pb-16{padding-bottom:calc(var(--spacing)*16)}.pb-20{padding-bottom:calc(var(--spacing)*20)}.pb-24{padding-bottom:calc(var(--spacing)*24)}.pb-25{padding-bottom:calc(var(--spacing)*25)}.pb-28{padding-bottom:calc(var(--spacing)*28)}.pb-32{padding-bottom:calc(var(--spacing)*32)}.pb-36{padding-bottom:calc(var(--spacing)*36)}.pb-44{padding-bottom:calc(var(--spacing)*44)}.pb-\[0\.25em\]{padding-bottom:.25em}.pb-\[1px\]{padding-bottom:1px}.pb-\[3\.5px\]{padding-bottom:3.5px}.pb-\[5svh\]{padding-bottom:5svh}.pb-\[10px\]{padding-bottom:10px}.pb-\[11px\]{padding-bottom:11px}.pb-\[13px\]{padding-bottom:13px}.pb-\[18px\]{padding-bottom:18px}.pb-\[22px\]{padding-bottom:22px}.pb-\[40px\]{padding-bottom:40px}.pb-\[84px\]{padding-bottom:84px}.pb-\[calc\(0\.5rem\+env\(safe-area-inset-bottom\)\)\]{padding-bottom:calc(.5rem + env(safe-area-inset-bottom))}.pb-\[calc\(0\.75rem\+env\(safe-area-inset-bottom\)\)\]{padding-bottom:calc(.75rem + env(safe-area-inset-bottom))}.pb-\[calc\(max\(env\(safe-area-inset-bottom\)\,0px\)\+4px\)\]{padding-bottom:calc(max(env(safe-area-inset-bottom),0px) + 4px)}.pb-\[calc\(max\(env\(safe-area-inset-bottom\)\,0px\)\+10px\)\]{padding-bottom:calc(max(env(safe-area-inset-bottom),0px) + 10px)}.pb-\[calc\(var\(--sidebar-section-margin-top\)-var\(--sidebar-section-first-margin-top\)\)\]{padding-bottom:calc(var(--sidebar-section-margin-top) - var(--sidebar-section-first-margin-top))}.pb-\[env\(safe-area-inset-bottom\,0px\)\]{padding-bottom:env(safe-area-inset-bottom,0px)}.pb-\[max\(3rem\,env\(safe-area-inset-bottom\,0px\)\+1\.25rem\)\]{padding-bottom:max(3rem,env(safe-area-inset-bottom,0px) + 1.25rem)}.pb-snc-1{padding-bottom:var(--snc-1)}.pb-snc-2{padding-bottom:var(--snc-2)}.\!pl-0{padding-left:calc(var(--spacing)*0)!important}.pl-0{padding-left:calc(var(--spacing)*0)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-4{padding-left:calc(var(--spacing)*4)}.pl-6{padding-left:calc(var(--spacing)*6)}.pl-12{padding-left:calc(var(--spacing)*12)}.pl-16{padding-left:calc(var(--spacing)*16)}.pl-20{padding-left:calc(var(--spacing)*20)}.text-center{text-align:center}.text-end{text-align:end}.text-justify{text-align:justify}.text-left{text-align:left}.text-right{text-align:right}.text-start{text-align:start}.indent-\[0\.1em\]{text-indent:.1em}.align-baseline{vertical-align:baseline}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.align-middle\!{vertical-align:middle!important}.align-sub{vertical-align:sub}.align-super{vertical-align:super}.align-text-bottom{vertical-align:text-bottom}.align-top{vertical-align:top}.font-\[\'SF_Pro_Text\'\,\'SF_Pro_Display\'\,\'SF_Pro\'\,var\(--font-sans\)\]{font-family:"SF Pro Text","SF Pro Display","SF Pro",var(--font-sans)}.font-\[\'Söhne\'\,\'Sohne\'\,var\(--font-sans\)\]{font-family:"Söhne","Sohne",var(--font-sans)}.font-circle{font-family:Circle,"system-ui","sans-serif"}.font-mono{font-family:"ui-monospace",SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,"monospace"}.font-oai{font-family:OpenAI Sans,sans-serif}.font-oai\!{font-family:OpenAI Sans,sans-serif!important}.font-sans{font-family:"ui-sans-serif",-apple-system,"system-ui",Segoe UI,Helvetica,Apple Color Emoji,Arial,"sans-serif",Segoe UI Emoji,Segoe UI Symbol}.font-serif{font-family:"ui-serif",Georgia,Cambria,Times New Roman,"serif"}.text-page-header{--tw-leading:34px;--tw-font-weight:var(--font-weight-normal);font-size:28px;line-height:34px;font-weight:var(--font-weight-normal);--tw-tracking:.38px;letter-spacing:.38px}.text-body-regular{font-size:var(--text-body-regular);line-height:var(--tw-leading,var(--text-body-regular--line-height));letter-spacing:var(--tw-tracking,var(--text-body-regular--letter-spacing));font-weight:var(--tw-font-weight,var(--text-body-regular--font-weight))}.text-body-small-regular{font-size:var(--text-body-small-regular);line-height:var(--tw-leading,var(--text-body-small-regular--line-height));letter-spacing:var(--tw-tracking,var(--text-body-small-regular--letter-spacing));font-weight:var(--tw-font-weight,var(--text-body-small-regular--font-weight))}.text-caption-regular{font-size:var(--text-caption-regular);line-height:var(--tw-leading,var(--text-caption-regular--line-height));letter-spacing:var(--tw-tracking,var(--text-caption-regular--letter-spacing));font-weight:var(--tw-font-weight,var(--text-caption-regular--font-weight))}.text-footnote-regular{font-size:var(--text-footnote-regular);line-height:var(--tw-leading,var(--text-footnote-regular--line-height));letter-spacing:var(--tw-tracking,var(--text-footnote-regular--letter-spacing));font-weight:var(--tw-font-weight,var(--text-footnote-regular--font-weight))}.text-heading-2{font-size:var(--text-heading-2);line-height:var(--tw-leading,var(--text-heading-2--line-height));letter-spacing:var(--tw-tracking,var(--text-heading-2--letter-spacing));font-weight:var(--tw-font-weight,var(--text-heading-2--font-weight))}.text-heading-2\!{font-size:var(--text-heading-2)!important;line-height:var(--tw-leading,var(--text-heading-2--line-height))!important;letter-spacing:var(--tw-tracking,var(--text-heading-2--letter-spacing))!important;font-weight:var(--tw-font-weight,var(--text-heading-2--font-weight))!important}.text-heading-3{font-size:var(--text-heading-3);line-height:var(--tw-leading,var(--text-heading-3--line-height));letter-spacing:var(--tw-tracking,var(--text-heading-3--letter-spacing));font-weight:var(--tw-font-weight,var(--text-heading-3--font-weight))}.text-heading-app{font-size:var(--text-heading-app);line-height:var(--tw-leading,var(--text-heading-app--line-height));letter-spacing:var(--tw-tracking,var(--text-heading-app--letter-spacing));font-weight:var(--tw-font-weight,var(--text-heading-app--font-weight))}.text-mkt-h1{font-size:max(2rem,min(3.0047vw + 1.29577rem,4rem));line-height:var(--tw-leading,clamp(2.2rem,calc(2.2rem + 1.8*((100vw - 23.4375rem)/66.5625)),4rem));letter-spacing:var(--tw-tracking,-.03em);font-weight:var(--tw-font-weight,500)}.text-mkt-h2{font-size:max(2rem,min(1.50235vw + 1.64789rem,3rem));line-height:var(--tw-leading,clamp(2.28rem,calc(2.28rem + 1.2*((100vw - 23.4375rem)/66.5625)),3.48rem));letter-spacing:var(--tw-tracking,clamp(-.03em,calc(-.03em + .02*((90rem - 100vw)/66.5625)),-.01em));font-weight:var(--tw-font-weight,500)}.text-mkt-h3{font-size:max(1.5rem,min(.56338vw + 1.36796rem,1.875rem));line-height:var(--tw-leading,clamp(1.98rem,calc(1.98rem + .495*((100vw - 23.4375rem)/66.5625)),2.475rem));letter-spacing:var(--tw-tracking,-.01em);font-weight:var(--tw-font-weight,500)}.text-mkt-h3\!{font-size:max(1.5rem,min(.56338vw + 1.36796rem,1.875rem))!important;line-height:var(--tw-leading,clamp(1.98rem,calc(1.98rem + .495*((100vw - 23.4375rem)/66.5625)),2.475rem))!important;letter-spacing:var(--tw-tracking,-.01em)!important;font-weight:var(--tw-font-weight,500)!important}.text-mkt-h4{font-size:max(1.25rem,min(.187793vw + 1.20599rem,1.375rem));line-height:var(--tw-leading,clamp(1.5rem,calc(1.5rem + .2325*((100vw - 23.4375rem)/66.5625)),1.7325rem));letter-spacing:var(--tw-tracking,-.01em);font-weight:var(--tw-font-weight,500)}.text-mkt-h5,.text-mkt-h6{font-size:max(1rem,min(.187793vw + .955986rem,1.125rem));line-height:var(--tw-leading,clamp(1.25rem,calc(1.25rem + .235*((100vw - 23.4375rem)/66.5625)),1.485rem));letter-spacing:var(--tw-tracking,-.01em);font-weight:var(--tw-font-weight,500)}.text-mkt-p1{font-size:1.0625rem;line-height:var(--tw-leading,1.74994rem);letter-spacing:var(--tw-tracking,-.01em);font-weight:var(--tw-font-weight,400)}.text-mkt-p2{font-size:.875rem;line-height:var(--tw-leading,1.435rem);letter-spacing:var(--tw-tracking,-.01em);font-weight:var(--tw-font-weight,400)}.text-mkt-xs{font-size:.625rem;line-height:var(--tw-leading,.825rem);letter-spacing:var(--tw-tracking,clamp(-.01em,calc(-.01em + .01*((90rem - 100vw)/66.5625)),0em));font-weight:var(--tw-font-weight,400)}.text-monospace{font-size:var(--text-monospace);line-height:var(--tw-leading,var(--text-monospace--line-height));letter-spacing:var(--tw-tracking,var(--text-monospace--letter-spacing));font-weight:var(--tw-font-weight,var(--text-monospace--font-weight))}.snc .text-xs{font-size:.825rem;line-height:1.4}.snc .text-sm{font-size:.9rem;line-height:1.4}.snc .text-base{font-size:1rem}.snc .text-lg{font-size:1.125rem}.snc .text-xl{font-size:1.25rem}.snc .text-2xl{font-size:1.5rem}.snc .text-3xl{font-size:1.875rem}@media (min-width:40rem){.snc{--snc-results-padding:1.5rem}}.\!text-xs{font-size:var(--text-xs)!important;line-height:var(--tw-leading,var(--text-xs--line-height))!important}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-7xl{font-size:var(--text-7xl);line-height:var(--tw-leading,var(--text-7xl--line-height))}.text-\[16px\]\/\[21px\]{font-size:16px;line-height:21px}.text-\[20px\]\/\[25px\]{font-size:20px;line-height:25px}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-base\!{font-size:var(--text-base)!important;line-height:var(--tw-leading,var(--text-base--line-height))!important}.text-body{font-size:15px;line-height:var(--tw-leading,22px)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-panel-title{font-size:17px;line-height:var(--tw-leading,26px)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\!{font-size:var(--text-sm)!important;line-height:var(--tw-leading,var(--text-sm--line-height))!important}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\!{font-size:var(--text-xs)!important;line-height:var(--tw-leading,var(--text-xs--line-height))!important}.\[font-size\:0\]{font-size:0}.\[font-size\:unset\]{font-size:unset}.text-\[0\.5em\]{font-size:.5em}.text-\[0\.5rem\]{font-size:.5rem}.text-\[0\.55em\]{font-size:.55em}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.70rem\]{font-size:.7rem}.text-\[0\.75rem\]{font-size:.75rem}.text-\[0\.93rem\]{font-size:.93rem}.text-\[0\.625rem\]{font-size:.625rem}.text-\[0\.5625em\]{font-size:.5625em}.text-\[0px\]{font-size:0}.text-\[1\.05rem\]{font-size:1.05rem}.text-\[1\.75rem\]{font-size:1.75rem}.text-\[1\.0625rem\]{font-size:1.0625rem}.text-\[2rem\]{font-size:2rem}.text-\[5px\]{font-size:5px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[11px\]\!{font-size:11px!important}.text-\[12px\]{font-size:12px}.text-\[12px\]\!{font-size:12px!important}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[15px\]\!{font-size:15px!important}.text-\[16px\]{font-size:16px}.text-\[16px\]\!{font-size:16px!important}.text-\[17px\]{font-size:17px}.text-\[17px\]\!{font-size:17px!important}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[21px\]{font-size:21px}.text-\[22px\]{font-size:22px}.text-\[23px\]{font-size:23px}.text-\[24px\]{font-size:24px}.text-\[25px\]{font-size:25px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-\[30px\]{font-size:30px}.text-\[32px\]{font-size:32px}.text-\[34px\]{font-size:34px}.text-\[36px\]{font-size:36px}.text-\[40px\]{font-size:40px}.text-\[42px\]{font-size:42px}.text-\[48px\]{font-size:48px}.text-\[52px\]{font-size:52px}.text-\[96px\]{font-size:96px}.text-\[clamp\(10px\,1\.8vw\,14px\)\]{font-size:max(10px,min(1.8vw,14px))}.text-\[clamp\(10px\,3\.2vw\,14px\)\]{font-size:max(10px,min(3.2vw,14px))}.text-\[clamp\(10px\,3\.4cqw\,14px\)\]{font-size:max(10px,min(3.4cqw,14px))}.text-\[clamp\(11px\,3\.6vw\,13px\)\]{font-size:max(11px,min(3.6vw,13px))}.text-\[min\(calc\(\(100vw-16px\)\*0\.231\)\,343px\)\]{font-size:min(23.1vw - 3.696px,343px)}.text-\[min\(calc\(\(100vw-32px\)\*0\.215\)\,318px\)\]{font-size:min(21.5vw - 6.88px,318px)}.\!leading-5{--tw-leading:calc(var(--spacing)*5)!important;line-height:calc(var(--spacing)*5)!important}.leading-0{--tw-leading:calc(var(--spacing)*0);line-height:calc(var(--spacing)*0)}.leading-3{--tw-leading:calc(var(--spacing)*3);line-height:calc(var(--spacing)*3)}.leading-3\.5{--tw-leading:calc(var(--spacing)*3.5);line-height:calc(var(--spacing)*3.5)}.leading-3\.75{--tw-leading:calc(var(--spacing)*3.75);line-height:calc(var(--spacing)*3.75)}.leading-4{--tw-leading:calc(var(--spacing)*4);line-height:calc(var(--spacing)*4)}.leading-4\.5{--tw-leading:calc(var(--spacing)*4.5);line-height:calc(var(--spacing)*4.5)}.leading-5{--tw-leading:calc(var(--spacing)*5);line-height:calc(var(--spacing)*5)}.leading-5\.5{--tw-leading:calc(var(--spacing)*5.5);line-height:calc(var(--spacing)*5.5)}.leading-6{--tw-leading:calc(var(--spacing)*6);line-height:calc(var(--spacing)*6)}.leading-7{--tw-leading:calc(var(--spacing)*7);line-height:calc(var(--spacing)*7)}.leading-8{--tw-leading:calc(var(--spacing)*8);line-height:calc(var(--spacing)*8)}.leading-8\.5{--tw-leading:calc(var(--spacing)*8.5);line-height:calc(var(--spacing)*8.5)}.leading-9{--tw-leading:calc(var(--spacing)*9);line-height:calc(var(--spacing)*9)}.leading-9\.5{--tw-leading:calc(var(--spacing)*9.5);line-height:calc(var(--spacing)*9.5)}.leading-10{--tw-leading:calc(var(--spacing)*10);line-height:calc(var(--spacing)*10)}.leading-\[0\]{--tw-leading:0;line-height:0}.leading-\[1\.1\]{--tw-leading:1.1;line-height:1.1}.leading-\[1\.2\]{--tw-leading:1.2;line-height:1.2}.leading-\[1\.4\]{--tw-leading:1.4;line-height:1.4}.leading-\[1\.05\]{--tw-leading:1.05;line-height:1.05}.leading-\[1\.08\]{--tw-leading:1.08;line-height:1.08}.leading-\[1\.25\]{--tw-leading:1.25;line-height:1.25}.leading-\[1\.35\]{--tw-leading:1.35;line-height:1.35}.leading-\[1\.45\]{--tw-leading:1.45;line-height:1.45}.leading-\[1\.55\]{--tw-leading:1.55;line-height:1.55}.leading-\[1\.375rem\]{--tw-leading:1.375rem;line-height:1.375rem}.leading-\[1\.625rem\]{--tw-leading:1.625rem;line-height:1.625rem}.leading-\[1\]{--tw-leading:1;line-height:1}.leading-\[1lh\]{--tw-leading:1lh;line-height:1lh}.leading-\[13px\]{--tw-leading:13px;line-height:13px}.leading-\[14px\]{--tw-leading:14px;line-height:14px}.leading-\[15px\]{--tw-leading:15px;line-height:15px}.leading-\[16px\]{--tw-leading:16px;line-height:16px}.leading-\[17px\]{--tw-leading:17px;line-height:17px}.leading-\[18px\]{--tw-leading:18px;line-height:18px}.leading-\[19px\]{--tw-leading:19px;line-height:19px}.leading-\[20px\]{--tw-leading:20px;line-height:20px}.leading-\[20px\]\!{--tw-leading:20px!important;line-height:20px!important}.leading-\[21px\]{--tw-leading:21px;line-height:21px}.leading-\[22px\]{--tw-leading:22px;line-height:22px}.leading-\[23px\]{--tw-leading:23px;line-height:23px}.leading-\[23px\]\!{--tw-leading:23px!important;line-height:23px!important}.leading-\[24px\]{--tw-leading:24px;line-height:24px}.leading-\[25px\]{--tw-leading:25px;line-height:25px}.leading-\[26px\]{--tw-leading:26px;line-height:26px}.leading-\[28px\]{--tw-leading:28px;line-height:28px}.leading-\[30px\]{--tw-leading:30px;line-height:30px}.leading-\[34px\]{--tw-leading:34px;line-height:34px}.leading-\[35px\]{--tw-leading:35px;line-height:35px}.leading-\[36px\]{--tw-leading:36px;line-height:36px}.leading-\[42px\]{--tw-leading:42px;line-height:42px}.leading-\[48px\]{--tw-leading:48px;line-height:48px}.leading-\[150\%\]{--tw-leading:150%;line-height:150%}.leading-\[clamp\(14px\,4\.2vw\,18px\)\]{--tw-leading:clamp(14px,4.2vw,18px);line-height:max(14px,min(4.2vw,18px))}.leading-\[normal\]{--tw-leading:normal;line-height:normal}.leading-bar{--tw-leading:var(--bar-gap,.25rem);line-height:var(--bar-gap,.25rem)}.leading-dense{--tw-leading:7/6;line-height:7/6}.leading-none{--tw-leading:1;line-height:1}.leading-none\!{--tw-leading:1!important;line-height:1!important}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-normal\!{--tw-leading:var(--leading-normal)!important;line-height:var(--leading-normal)!important}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.\[line-height\:1lh\]{line-height:1lh}.\!font-normal{--tw-font-weight:var(--font-weight-normal)!important;font-weight:var(--font-weight-normal)!important}.font-\[14px\]{--tw-font-weight:14px;font-weight:14px}.font-\[350\]{--tw-font-weight:350;font-weight:350}.font-\[400\]{--tw-font-weight:400;font-weight:400}.font-\[450\]{--tw-font-weight:450;font-weight:450}.font-\[500\]{--tw-font-weight:500;font-weight:500}.font-\[510\]{--tw-font-weight:510;font-weight:510}.font-\[550\]{--tw-font-weight:550;font-weight:550}.font-\[590\]{--tw-font-weight:590;font-weight:590}.font-\[600\]{--tw-font-weight:600;font-weight:600}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extralight{--tw-font-weight:var(--font-weight-extralight);font-weight:var(--font-weight-extralight)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-medium\!{--tw-font-weight:var(--font-weight-medium)!important;font-weight:var(--font-weight-medium)!important}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-normal\!{--tw-font-weight:var(--font-weight-normal)!important;font-weight:var(--font-weight-normal)!important}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.font-semibold\!{--tw-font-weight:var(--font-weight-semibold)!important;font-weight:var(--font-weight-semibold)!important}.\[font-weight\:500\]{font-weight:500}.\[font-weight\:700\]{font-weight:700}.tracking-\[-0\.01em\]{--tw-tracking:-.01em;letter-spacing:-.01em}.tracking-\[-0\.1px\]{--tw-tracking:-.1px;letter-spacing:-.1px}.tracking-\[-0\.02em\]{--tw-tracking:-.02em;letter-spacing:-.02em}.tracking-\[-0\.2px\]{--tw-tracking:-.2px;letter-spacing:-.2px}.tracking-\[-0\.03em\]{--tw-tracking:-.03em;letter-spacing:-.03em}.tracking-\[-0\.3px\]{--tw-tracking:-.3px;letter-spacing:-.3px}.tracking-\[-0\.04em\]{--tw-tracking:-.04em;letter-spacing:-.04em}.tracking-\[-0\.4px\]{--tw-tracking:-.4px;letter-spacing:-.4px}.tracking-\[-0\.5px\]{--tw-tracking:-.5px;letter-spacing:-.5px}.tracking-\[-0\.06px\]{--tw-tracking:-.06px;letter-spacing:-.06px}.tracking-\[-0\.08px\]{--tw-tracking:-.08px;letter-spacing:-.08px}.tracking-\[-0\.12px\]{--tw-tracking:-.12px;letter-spacing:-.12px}.tracking-\[-0\.015em\]{--tw-tracking:-.015em;letter-spacing:-.015em}.tracking-\[-0\.16px\]{--tw-tracking:-.16px;letter-spacing:-.16px}.tracking-\[-0\.18px\]{--tw-tracking:-.18px;letter-spacing:-.18px}.tracking-\[-0\.24px\]{--tw-tracking:-.24px;letter-spacing:-.24px}.tracking-\[-0\.25px\]{--tw-tracking:-.25px;letter-spacing:-.25px}.tracking-\[-0\.26px\]{--tw-tracking:-.26px;letter-spacing:-.26px}.tracking-\[-0\.27px\]{--tw-tracking:-.27px;letter-spacing:-.27px}.tracking-\[-0\.30px\]{--tw-tracking:-.3px;letter-spacing:-.3px}.tracking-\[-0\.31px\]{--tw-tracking:-.31px;letter-spacing:-.31px}.tracking-\[-0\.32px\]{--tw-tracking:-.32px;letter-spacing:-.32px}.tracking-\[-0\.43px\]{--tw-tracking:-.43px;letter-spacing:-.43px}.tracking-\[-0\.45px\]{--tw-tracking:-.45px;letter-spacing:-.45px}.tracking-\[-0\.46px\]{--tw-tracking:-.46px;letter-spacing:-.46px}.tracking-\[-0\.154px\]{--tw-tracking:-.154px;letter-spacing:-.154px}.tracking-\[-0\.197499px\]{--tw-tracking:-.197499px;letter-spacing:-.197499px}.tracking-\[-1\.2px\]{--tw-tracking:-1.2px;letter-spacing:-1.2px}.tracking-\[-1\.5px\]{--tw-tracking:-1.5px;letter-spacing:-1.5px}.tracking-\[0\.01em\]{--tw-tracking:.01em;letter-spacing:.01em}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.06px\]{--tw-tracking:.06px;letter-spacing:.06px}.tracking-\[0\.08em\]{--tw-tracking:.08em;letter-spacing:.08em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-\[0\.24em\]{--tw-tracking:.24em;letter-spacing:.24em}.tracking-\[0\.38px\]{--tw-tracking:.38px;letter-spacing:.38px}.tracking-\[0\.392px\]{--tw-tracking:.392px;letter-spacing:.392px}.tracking-\[0\]{--tw-tracking:0;letter-spacing:0}.tracking-\[0px\]{--tw-tracking:0px;letter-spacing:0}.tracking-condensed{--tw-tracking:-.154px;letter-spacing:-.154px}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.\[text-wrap\:pretty\]{text-wrap:pretty}.text-balance{text-wrap:balance}.text-nowrap{text-wrap:nowrap}.text-pretty{text-wrap:pretty}.text-wrap{text-wrap:wrap}.break-normal{overflow-wrap:normal;word-break:normal}.\[overflow-wrap\:anywhere\]{overflow-wrap:anywhere}.\[overflow-wrap\:normal\]{overflow-wrap:normal}.break-words{overflow-wrap:break-word}.wrap-anywhere{overflow-wrap:anywhere}.wrap-break-word{overflow-wrap:break-word}.\[word-break\:normal\]{word-break:normal}.break-all{word-break:break-all}.overflow-ellipsis{text-overflow:ellipsis}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.hyphens-auto{-webkit-hyphens:auto;hyphens:auto}.\[hyphens\:none\]{-webkit-hyphens:none;hyphens:none}.whitespace-break-spaces{white-space:break-spaces}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-nowrap\!{white-space:nowrap!important}.whitespace-pre{white-space:pre}.whitespace-pre\!{white-space:pre!important}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.whitespace-pre-wrap\!{white-space:pre-wrap!important}.interactive-icon-accent{color:var(--interactive-icon-default-accent)}@media (hover:hover){.interactive-icon-accent:hover{color:var(--interactive-icon-hover-accent)}}.interactive-icon-accent:focus-visible{color:var(--interactive-icon-hover-accent)}.interactive-icon-accent:disabled,.interactive-icon-accent:where([data-visually-disabled]){color:var(--interactive-icon-inactive-accent)}.interactive-icon-accent:checked{color:var(--interactive-icon-selected-accent)}.interactive-icon-accent:active{color:var(--interactive-icon-press-accent)}.interactive-label-accent{color:var(--interactive-label-default-accent)}@media (hover:hover){.interactive-label-accent:hover{color:var(--interactive-label-hover-accent)}}.interactive-label-accent:focus-visible{color:var(--interactive-label-hover-accent)}.interactive-label-accent:disabled,.interactive-label-accent:where([data-visually-disabled]){color:var(--interactive-label-inactive-accent)}.interactive-label-accent:checked{color:var(--interactive-label-selected-accent)}.interactive-label-accent:active{color:var(--interactive-label-press-accent)}.interactive-label-primary{color:var(--interactive-label-default-primary)}@media (hover:hover){.interactive-label-primary:hover{color:var(--interactive-label-hover-primary)}}.interactive-label-primary:focus-visible{color:var(--interactive-label-hover-primary)}.interactive-label-primary:disabled,.interactive-label-primary:where([data-visually-disabled]){color:var(--interactive-label-inactive-primary)}.interactive-label-primary:checked{color:var(--interactive-label-selected-primary)}.interactive-label-primary:active{color:var(--interactive-label-press-primary)}.interactive-label-secondary{color:var(--interactive-label-default-secondary)}@media (hover:hover){.interactive-label-secondary:hover{color:var(--interactive-label-hover-secondary)}}.interactive-label-secondary:focus-visible{color:var(--interactive-label-hover-secondary)}.interactive-label-secondary:disabled,.interactive-label-secondary:where([data-visually-disabled]){color:var(--interactive-label-inactive-secondary)}.interactive-label-secondary:checked{color:var(--interactive-label-selected-secondary)}.interactive-label-secondary:active{color:var(--interactive-label-press-secondary)}.interactive-label-tertiary{color:var(--interactive-label-default-tertiary)}@media (hover:hover){.interactive-label-tertiary:hover{color:var(--interactive-label-hover-tertiary)}}.interactive-label-tertiary:focus-visible{color:var(--interactive-label-hover-tertiary)}.interactive-label-tertiary:disabled,.interactive-label-tertiary:where([data-visually-disabled]){color:var(--interactive-label-inactive-tertiary)}.interactive-label-tertiary:checked{color:var(--interactive-label-selected-tertiary)}.interactive-label-tertiary:active{color:var(--interactive-label-press-tertiary)}.\!text-\[\#0D0D0D\]{color:#0d0d0d!important}.\!text-\[\#007aff\]{color:#007aff!important}.\!text-gray-900{color:var(--gray-900)!important}.\!text-token-text-primary{color:var(--text-primary)!important}.\!text-white{color:#fff!important}.entity-accent{color:var(--theme-entity-accent)}.text-\(--theme-user-msg-text\){color:var(--theme-user-msg-text)}.text-\[\#0B69FF\]{color:#0b69ff}.text-\[\#0D0D0D\]{color:#0d0d0d}.text-\[\#0FA968\]{color:#0fa968}.text-\[\#0f9f6e\]{color:#0f9f6e}.text-\[\#00A240\],.text-\[\#00a240\]{color:#00a240}.text-\[\#1A59F8\]{color:#1a59f8}.text-\[\#1f6f45\]{color:#1f6f45}.text-\[\#2D7A43\]{color:#2d7a43}.text-\[\#2a7fff\]{color:#2a7fff}.text-\[\#2f8a56\]{color:#2f8a56}.text-\[\#3DCB40\]{color:#3dcb40}.text-\[\#004F99\]{color:#004f99}.text-\[\#004f1f\]{color:#004f1f}.text-\[\#5B8AF0\]{color:#5b8af0}.text-\[\#5D5BD0\]{color:#5d5bd0}.text-\[\#5D5D5D\]{color:#5d5d5d}.text-\[\#6B7280\]{color:#6b7280}.text-\[\#007AFF\],.text-\[\#007aff\]{color:#007aff}.text-\[\#008C2E\]{color:#008c2e}.text-\[\#8E3CF3\]{color:#8e3cf3}.text-\[\#8F8F8F\],.text-\[\#8f8f8f\]{color:#8f8f8f}.text-\[\#10A37F\],.text-\[\#10a37f\]{color:#10a37f}.text-\[\#30a633\]{color:#30a633}.text-\[\#0088FF\]{color:#08f}.text-\[\#262B72\]{color:#262b72}.text-\[\#0285FF\],.text-\[\#0285ff\]{color:#0285ff}.text-\[\#0385FF\]{color:#0385ff}.text-\[\#615EEB\]{color:#615eeb}.text-\[\#923B0F\]{color:#923b0f}.text-\[\#3794FF\]{color:#3794ff}.text-\[\#3855EA\]{color:#3855ea}.text-\[\#4285F4\]{color:#4285f4}.text-\[\#4362A0\]{color:#4362a0}.text-\[\#5856D6\]{color:#5856d6}.text-\[\#008000\]{color:green}.text-\[\#24622B\]{color:#24622b}.text-\[\#53565A\]{color:#53565a}.text-\[\#59636E\]{color:#59636e}.text-\[\#94673b\]{color:#94673b}.text-\[\#101828\]\!{color:#101828!important}.text-\[\#667085\]{color:#667085}.text-\[\#667085\]\!{color:#667085!important}.text-\[\#AF52DE\]{color:#af52de}.text-\[\#C23B46\]{color:#c23b46}.text-\[\#D6303D\]{color:#d6303d}.text-\[\#E25507\]{color:#e25507}.text-\[\#EE4D83\]{color:#ee4d83}.text-\[\#F75858\]{color:#f75858}.text-\[\#FE7600\]{color:#fe7600}.text-\[\#a96e25\]{color:#a96e25}.text-\[\#afafaf\]\!{color:#afafaf!important}.text-\[\#df1b41\]{color:#df1b41}.text-\[\#e02e2a\]{color:#e02e2a}.text-\[\#e29e00\]{color:#e29e00}.text-\[\#f14d42\]{color:#f14d42}.text-\[\#ff002a\]{color:#ff002a}.text-\[black\]{color:#000}.text-\[rgb\(18\,100\,163\)\]{color:#1264a3}.text-\[rgba\(0\,0\,0\,0\.6\)\]{color:#0009}.text-\[rgba\(2\,133\,255\,1\)\]{color:#0285ff}.text-\[var\(--bg-primary\)\]{color:var(--bg-primary)}.text-\[var\(--green-600\)\]{color:var(--green-600)}.text-\[var\(--input-font-size\)\]{color:var(--input-font-size)}.text-\[var\(--interactive-label-accent-default\)\]{color:var(--interactive-label-accent-default)}.text-\[var\(--main-surface-primary-inverse\)\]{color:var(--main-surface-primary-inverse)}.text-\[var\(--sidebar-surface-secondary\)\]{color:var(--sidebar-surface-secondary)}.text-\[var\(--text-danger\)\]{color:var(--text-danger)}.text-\[var\(--theme-user-msg-text\)\]{color:var(--theme-user-msg-text)}.text-black{color:#000}.text-black\!{color:#000!important}.text-black\/20{color:#0003;color:lab(0% 0 0/.2)}.text-black\/35{color:#00000059;color:lab(0% 0 0/.35)}.text-black\/45{color:#00000073;color:lab(0% 0 0/.45)}.text-black\/60{color:#0009;color:lab(0% 0 0/.6)}.text-black\/80{color:#000c;color:lab(0% 0 0/.8)}.text-blue-100{color:var(--blue-100)}.text-blue-200{color:var(--blue-200)}.text-blue-300{color:var(--blue-300)}.text-blue-400{color:var(--blue-400)}.text-blue-400\!{color:var(--blue-400)!important}.text-blue-500{color:var(--blue-500)}.text-blue-600{color:var(--blue-600)}.text-blue-700{color:var(--blue-700)}.text-blue-800{color:var(--blue-800)}.text-blue-900{color:var(--blue-900)}.text-brand-blue-800{color:#0066de}.text-brand-green-800{color:#05a746}.text-brand-purple{color:#ab68ff}.text-brand-purple-800{color:#5400de}.text-current{color:currentColor}.text-danger{color:var(--red-500)}.text-gray-100{color:var(--gray-100)}.text-gray-200{color:var(--gray-200)}.text-gray-300{color:var(--gray-300)}.text-gray-400{color:var(--gray-400)}.text-gray-500{color:var(--gray-500)}.text-gray-600{color:var(--gray-600)}.text-gray-700{color:var(--gray-700)}.text-gray-800{color:var(--gray-800)}.text-gray-900{color:var(--gray-900)}.text-gray-950,.text-gray-950\/80{color:var(--gray-950)}@supports (color:color-mix(in lab, red, red)){.text-gray-950\/80{color:color-mix(in oklab,var(--gray-950)80%,transparent)}}.text-gray-solid-500{color:#5d5d5d}.text-green-200{color:var(--green-200)}.text-green-300{color:var(--green-300)}.text-green-400{color:var(--green-400)}.text-green-500{color:var(--green-500)}.text-green-600{color:var(--green-600)}.text-green-700{color:var(--green-700)}.text-green-800{color:var(--green-800)}.text-green-900{color:var(--green-900)}.text-green-950{color:var(--green-950)}.text-inherit{color:inherit}.text-orange-300{color:var(--orange-300)}.text-orange-400{color:var(--orange-400)}.text-orange-500{color:var(--orange-500)}.text-orange-600{color:var(--orange-600)}.text-orange-700{color:var(--orange-700)}.text-orange-800{color:var(--orange-800)}.text-pink-400{color:var(--pink-400)}.text-pink-500{color:var(--pink-500)}.text-pink-800{color:var(--pink-800)}.text-purple-200{color:var(--purple-200)}.text-purple-400{color:var(--purple-400)}.text-purple-500{color:var(--purple-500)}.text-purple-800{color:var(--purple-800)}.text-red-100{color:var(--red-100)}.text-red-200{color:var(--red-200)}.text-red-300{color:var(--red-300)}.text-red-400,.text-red-400\/30{color:var(--red-400)}@supports (color:color-mix(in lab, red, red)){.text-red-400\/30{color:color-mix(in oklab,var(--red-400)30%,transparent)}}.text-red-500{color:var(--red-500)}.text-red-600{color:var(--red-600)}.text-red-700{color:var(--red-700)}.text-red-800{color:var(--red-800)}.text-red-900{color:var(--red-900)}.text-red-950{color:var(--red-950)}.text-token-bg-accent-static{color:var(--bg-accent-static)}.text-token-bg-primary{color:var(--bg-primary)}.text-token-bg-secondary{color:var(--bg-secondary)}.text-token-border-default{color:var(--border-default)}.text-token-border-heavy{color:var(--border-heavy)}.text-token-icon-accent{color:var(--icon-accent)}.text-token-icon-accent\!{color:var(--icon-accent)!important}.text-token-icon-primary{color:var(--icon-primary)}.text-token-icon-secondary{color:var(--icon-secondary)}.text-token-icon-status-error{color:var(--icon-status-error)}.text-token-icon-status-warning{color:var(--icon-status-warning)}.text-token-icon-tertiary,.text-token-icon-tertiary\/80{color:var(--icon-tertiary)}@supports (color:color-mix(in lab, red, red)){.text-token-icon-tertiary\/80{color:color-mix(in oklab,var(--icon-tertiary)80%,transparent)}}.text-token-interactive-icon-accent-default{color:var(--interactive-icon-accent-default)}.text-token-interactive-icon-tertiary-default{color:var(--interactive-icon-tertiary-default)}.text-token-interactive-label-accent-default{color:var(--interactive-label-accent-default)}.text-token-interactive-label-danger-secondary-default{color:var(--interactive-label-danger-secondary-default)}.text-token-interactive-label-primary-default{color:var(--interactive-label-primary-default)}.text-token-interactive-label-primary-press{color:var(--interactive-label-primary-press)}.text-token-interactive-label-secondary-default{color:var(--interactive-label-secondary-default)}.text-token-interactive-label-tertiary-inactive{color:var(--interactive-label-tertiary-inactive)}.text-token-link{color:var(--link)}.text-token-main-surface-primary{color:var(--main-surface-primary)}.text-token-main-surface-primary\!{color:var(--main-surface-primary)!important}.text-token-main-surface-primary-inverse{color:var(--main-surface-primary-inverse)}.text-token-main-surface-secondary{color:var(--main-surface-secondary)}.text-token-main-surface-tertiary{color:var(--main-surface-tertiary)}.text-token-sidebar-surface{color:var(--sidebar-surface)}.text-token-text-accent{color:var(--text-accent)}.text-token-text-error{color:var(--text-error)}.text-token-text-inverted{color:var(--text-inverted)}.text-token-text-inverted-static{color:var(--text-inverted-static)}.text-token-text-primary{color:var(--text-primary)}.text-token-text-primary\!{color:var(--text-primary)!important}.text-token-text-primary\/35{color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.text-token-text-primary\/35{color:color-mix(in oklab,var(--text-primary)35%,transparent)}}.text-token-text-primary\/44{color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.text-token-text-primary\/44{color:color-mix(in oklab,var(--text-primary)44%,transparent)}}.text-token-text-primary\/50{color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.text-token-text-primary\/50{color:color-mix(in oklab,var(--text-primary)50%,transparent)}}.text-token-text-primary\/60{color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.text-token-text-primary\/60{color:color-mix(in oklab,var(--text-primary)60%,transparent)}}.text-token-text-primary\/80{color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.text-token-text-primary\/80{color:color-mix(in oklab,var(--text-primary)80%,transparent)}}.text-token-text-primary\/90{color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.text-token-text-primary\/90{color:color-mix(in oklab,var(--text-primary)90%,transparent)}}.text-token-text-quaternary{color:var(--text-quaternary)}.text-token-text-secondary{color:var(--text-secondary)}.text-token-text-secondary\!{color:var(--text-secondary)!important}.text-token-text-secondary\/70{color:var(--text-secondary)}@supports (color:color-mix(in lab, red, red)){.text-token-text-secondary\/70{color:color-mix(in oklab,var(--text-secondary)70%,transparent)}}.text-token-text-secondary\/75{color:var(--text-secondary)}@supports (color:color-mix(in lab, red, red)){.text-token-text-secondary\/75{color:color-mix(in oklab,var(--text-secondary)75%,transparent)}}.text-token-text-status-error{color:var(--text-status-error)}.text-token-text-status-warning{color:var(--text-status-warning)}.text-token-text-tertiary,.text-token-text-tertiary\/60{color:var(--text-tertiary)}@supports (color:color-mix(in lab, red, red)){.text-token-text-tertiary\/60{color:color-mix(in oklab,var(--text-tertiary)60%,transparent)}}.text-token-text-tertiary\/80{color:var(--text-tertiary)}@supports (color:color-mix(in lab, red, red)){.text-token-text-tertiary\/80{color:color-mix(in oklab,var(--text-tertiary)80%,transparent)}}.text-transparent{color:#0000}.text-white{color:#fff}.text-white\!{color:#fff!important}.text-white\/25{color:#ffffff40;color:lab(100% -.0000298023 .0000119209/.25)}.text-white\/50{color:#ffffff80;color:lab(100% -.0000298023 .0000119209/.5)}.text-white\/55{color:#ffffff8c;color:lab(100% -.0000298023 .0000119209/.55)}.text-white\/60{color:#fff9;color:lab(100% -.0000298023 .0000119209/.6)}.text-white\/65{color:#ffffffa6;color:lab(100% -.0000298023 .0000119209/.65)}.text-white\/70{color:#ffffffb3;color:lab(100% -.0000298023 .0000119209/.7)}.text-white\/75{color:#ffffffbf;color:lab(100% -.0000298023 .0000119209/.75)}.text-white\/80{color:#fffc;color:lab(100% -.0000298023 .0000119209/.8)}.text-white\/85{color:#ffffffd9;color:lab(100% -.0000298023 .0000119209/.85)}.text-white\/90{color:#ffffffe6;color:lab(100% -.0000298023 .0000119209/.9)}.text-white\/95{color:#fffffff2;color:lab(100% -.0000298023 .0000119209/.95)}.text-yellow-200{color:var(--yellow-200)}.text-yellow-400{color:var(--yellow-400)}.text-yellow-500{color:var(--yellow-500)}.text-yellow-600{color:var(--yellow-600)}.text-yellow-700{color:var(--yellow-700)}.text-yellow-800{color:var(--yellow-800)}.text-yellow-900{color:var(--yellow-900)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.italic\!{font-style:italic!important}.not-italic{font-style:normal}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.line-through{-webkit-text-decoration-line:line-through;text-decoration-line:line-through}.no-underline{-webkit-text-decoration-line:none;text-decoration-line:none}.no-underline\!{-webkit-text-decoration-line:none!important;text-decoration-line:none!important}.underline{-webkit-text-decoration-line:underline;text-decoration-line:underline}.underline\!{-webkit-text-decoration-line:underline!important;text-decoration-line:underline!important}.decoration-blue-100{-webkit-text-decoration-color:var(--blue-100);-webkit-text-decoration-color:var(--blue-100);-webkit-text-decoration-color:var(--blue-100);-webkit-text-decoration-color:var(--blue-100);text-decoration-color:var(--blue-100)}.decoration-gray-300{-webkit-text-decoration-color:var(--gray-300);-webkit-text-decoration-color:var(--gray-300);-webkit-text-decoration-color:var(--gray-300);-webkit-text-decoration-color:var(--gray-300);text-decoration-color:var(--gray-300)}.decoration-gray-500{-webkit-text-decoration-color:var(--gray-500);-webkit-text-decoration-color:var(--gray-500);-webkit-text-decoration-color:var(--gray-500);-webkit-text-decoration-color:var(--gray-500);text-decoration-color:var(--gray-500)}.decoration-red-100{-webkit-text-decoration-color:var(--red-100);-webkit-text-decoration-color:var(--red-100);-webkit-text-decoration-color:var(--red-100);-webkit-text-decoration-color:var(--red-100);text-decoration-color:var(--red-100)}.decoration-token-border-heavy{-webkit-text-decoration-color:var(--border-heavy);-webkit-text-decoration-color:var(--border-heavy);-webkit-text-decoration-color:var(--border-heavy);-webkit-text-decoration-color:var(--border-heavy);text-decoration-color:var(--border-heavy)}.decoration-token-link{-webkit-text-decoration-color:var(--link);-webkit-text-decoration-color:var(--link);-webkit-text-decoration-color:var(--link);-webkit-text-decoration-color:var(--link);text-decoration-color:var(--link)}.decoration-token-text-primary{-webkit-text-decoration-color:var(--text-primary);-webkit-text-decoration-color:var(--text-primary);-webkit-text-decoration-color:var(--text-primary);-webkit-text-decoration-color:var(--text-primary);text-decoration-color:var(--text-primary)}.decoration-token-text-secondary{-webkit-text-decoration-color:var(--text-secondary);-webkit-text-decoration-color:var(--text-secondary);-webkit-text-decoration-color:var(--text-secondary);-webkit-text-decoration-color:var(--text-secondary);text-decoration-color:var(--text-secondary)}.decoration-token-text-tertiary{-webkit-text-decoration-color:var(--text-tertiary);-webkit-text-decoration-color:var(--text-tertiary);-webkit-text-decoration-color:var(--text-tertiary);-webkit-text-decoration-color:var(--text-tertiary);text-decoration-color:var(--text-tertiary)}.decoration-token-text-tertiary\!{-webkit-text-decoration-color:var(--text-tertiary)!important;-webkit-text-decoration-color:var(--text-tertiary)!important;-webkit-text-decoration-color:var(--text-tertiary)!important;-webkit-text-decoration-color:var(--text-tertiary)!important;text-decoration-color:var(--text-tertiary)!important}.decoration-token-text-tertiary\/70{-webkit-text-decoration-color:var(--text-tertiary);-webkit-text-decoration-color:var(--text-tertiary);-webkit-text-decoration-color:var(--text-tertiary);-webkit-text-decoration-color:var(--text-tertiary);text-decoration-color:var(--text-tertiary)}@supports (color:color-mix(in lab, red, red)){.decoration-token-text-tertiary\/70{-webkit-text-decoration-color:color-mix(in oklab,var(--text-tertiary)70%,transparent);-webkit-text-decoration-color:color-mix(in oklab,var(--text-tertiary)70%,transparent);-webkit-text-decoration-color:color-mix(in oklab,var(--text-tertiary)70%,transparent);-webkit-text-decoration-color:color-mix(in oklab,var(--text-tertiary)70%,transparent);text-decoration-color:color-mix(in oklab,var(--text-tertiary)70%,transparent)}}.decoration-dashed{-webkit-text-decoration-style:dashed;text-decoration-style:dashed}.decoration-dotted{-webkit-text-decoration-style:dotted;text-decoration-style:dotted}.decoration-solid{-webkit-text-decoration-style:solid;text-decoration-style:solid}.decoration-1{text-decoration-thickness:1px}.decoration-2{text-decoration-thickness:2px}.decoration-\[0\.5px\]{text-decoration-thickness:.5px}.decoration-\[1px\]{text-decoration-thickness:1px}.decoration-\[8\%\]{text-decoration-thickness:.08em}.decoration-\[11\%\]{text-decoration-thickness:.11em}.decoration-\[12\%\]{text-decoration-thickness:.12em}.underline-offset-1{text-underline-offset:1px}.underline-offset-2{text-underline-offset:2px}.underline-offset-3{text-underline-offset:3px}.underline-offset-4{text-underline-offset:4px}.underline-offset-8{text-underline-offset:8px}.underline-offset-\[2px\]{text-underline-offset:2px}.underline-offset-\[6px\]{text-underline-offset:6px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.placeholder-gray-500::placeholder{color:var(--gray-500)}.placeholder-token-text-tertiary::placeholder{color:var(--text-tertiary)}.caret-current{caret-color:currentColor}.caret-token-text-primary{caret-color:var(--text-primary)}.accent-blue-500{accent-color:var(--blue-500)}.accent-token-text-primary{accent-color:var(--text-primary)}.opacity-0{opacity:0}.opacity-10{opacity:.1}.opacity-15{opacity:.15}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-35{opacity:.35}.opacity-40{opacity:.4}.opacity-45{opacity:.45}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-65{opacity:.65}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-80\!{opacity:.8!important}.opacity-85{opacity:.85}.opacity-90{opacity:.9}.opacity-100{opacity:1}.opacity-\[0\.01\]{opacity:.01}.opacity-\[0\.04\]{opacity:.04}.opacity-\[0\.12\]{opacity:.12}.opacity-\[0\.15\]{opacity:.15}.opacity-\[0\.34\]{opacity:.34}.\[background-blend-mode\:color-dodge\]{background-blend-mode:color-dodge}.mix-blend-darken{mix-blend-mode:darken}.mix-blend-difference{mix-blend-mode:difference}.mix-blend-soft-light{mix-blend-mode:soft-light}.shadow-long{--tw-shadow:0px 8px 12px 0px var(--tw-shadow-color,var(--shadow-color-1,#00000014)),0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#0000009e));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-long:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 8px 16px 0px var(--tw-shadow-color,#00000052),inset 0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#fff3)),0px 0px 1px 0px var(--tw-shadow-color,#0000009e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-short{--tw-shadow:0px 4px 4px 0px var(--tw-shadow-color,var(--shadow-color-1,#0000000a)),0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#0000009e));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-short:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 4px 12px 0px var(--tw-shadow-color,var(--shadow-color-1,#0000001a)),inset 0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#fff3));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-short-composer{--tw-shadow:0px 4px 4px 0px var(--tw-shadow-color,var(--shadow-color-1,#0000000a)),0px 4px 80px 8px var(--tw-shadow-color,var(--shadow-color-1,#0000000a)),0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#0000009e));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-short-composer:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 4px 4px 0px var(--tw-shadow-color,var(--shadow-color-1,#0000000d)),0px 4px 80px 0px var(--tw-shadow-color,var(--shadow-color-1,#0000000d)),inset 0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#fff3));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs\/3{--tw-shadow-alpha:3%;--tw-shadow:0 0 15px var(--tw-shadow-color,#00000008);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}@supports (color:lab(0% 0 0)){.shadow-xs\/3{--tw-shadow:0 0 15px var(--tw-shadow-color,lab(0% 0 0/.03))}}.\!shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a)!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow\!{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a)!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow-\(--shadow-lg\){--tw-shadow:var(--shadow-lg);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[-4px_0_0_0_var\(--bg-primary\)\]{--tw-shadow:-4px 0 0 0 var(--tw-shadow-color,var(--bg-primary));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_-4px_32px_rgba\(0\,0\,0\,0\.08\)\]{--tw-shadow:0 -4px 32px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_0\.5px_rgba\(13\,13\,13\,0\.1\)\,0_2px_20px_rgba\(0\,0\,0\,0\.08\)\]{--tw-shadow:0 0 0 .5px var(--tw-shadow-color,#0d0d0d1a),0 2px 20px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(15\,23\,42\,0\.45\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,#0f172a73);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(16\,163\,127\,0\.35\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,#10a37f59);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(16\,185\,129\,0\.25\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,#10b98140);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_1px_rgba\(236\,236\,251\,0\.95\)\,0_12px_24px_-18px_rgba\(93\,91\,208\,0\.45\)\,0_0_24px_rgba\(205\,206\,249\,0\.55\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,#ececfbf2),0 12px 24px -18px var(--tw-shadow-color,#5d5bd073),0 0 24px var(--tw-shadow-color,#cdcef98c);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_2px_rgba\(16\,163\,127\,0\.18\)\]{--tw-shadow:0 0 0 2px var(--tw-shadow-color,#10a37f2e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_2px_rgba\(16\,163\,127\,0\.25\)\]{--tw-shadow:0 0 0 2px var(--tw-shadow-color,#10a37f40);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_2px_rgba\(16\,163\,127\,0\.35\)\]{--tw-shadow:0 0 0 2px var(--tw-shadow-color,#10a37f59);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_2px_rgba\(148\,163\,184\,0\.35\)\]{--tw-shadow:0 0 0 2px var(--tw-shadow-color,#94a3b859);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_4px\]{--tw-shadow:0 0 0 4px var(--tw-shadow-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_4px_rgba\(255\,255\,255\,0\.18\)\]{--tw-shadow:0 0 0 4px var(--tw-shadow-color,#ffffff2e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_12px_0_rgba\(23\,98\,252\,0\.50\)\]{--tw-shadow:0 0 12px 0 var(--tw-shadow-color,#1762fc80);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_18px_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0 0 18px var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_24px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 0 24px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_24px_rgba\(0\,0\,0\,0\.07\)\,_0_16px_32px_-16px_rgba\(0\,0\,0\,0\.07\)\]{--tw-shadow:0 0 24px var(--tw-shadow-color,#00000012),0 16px 32px -16px var(--tw-shadow-color,#00000012);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_24px_rgba\(0\,0\,0\,0\.08\)\]{--tw-shadow:0 0 24px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_40px\]{--tw-shadow:0 0 40px var(--tw-shadow-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_50px\]{--tw-shadow:0 0 50px var(--tw-shadow-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_64px_0_rgba\(0\,0\,0\,0\.07\)\]{--tw-shadow:0 0 64px 0 var(--tw-shadow-color,#00000012);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_84px_0_rgba\(0\,105\,209\,0\.5\)\]{--tw-shadow:0 0 84px 0 var(--tw-shadow-color,#0069d180);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_1px_0\]{--tw-shadow:0 1px 0 var(--tw-shadow-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_1px_0_0_var\(--border-light\)\]{--tw-shadow:0 1px 0 0 var(--tw-shadow-color,var(--border-light));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_1px_1px_rgba\(0\,0\,0\,0\.03\)\,_0_4\.93747px_9\.05202px_rgba\(0\,0\,0\,0\.11\)\]{--tw-shadow:0 1px 1px var(--tw-shadow-color,#00000008),0 4.93747px 9.05202px var(--tw-shadow-color,#0000001c);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_1px_2px_rgba\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 1px 2px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_1px_2px_rgba\(0\,0\,0\,0\.06\)\]{--tw-shadow:0 1px 2px var(--tw-shadow-color,#0000000f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_1px_2px_rgba\(15\,23\,42\,0\.03\)\]{--tw-shadow:0 1px 2px var(--tw-shadow-color,#0f172a08);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_1px_4px_rgba\(15\,23\,42\,0\.08\)\]{--tw-shadow:0 1px 4px var(--tw-shadow-color,#0f172a14);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_2px_3px_0_rgba\(0\,0\,0\,0\.25\)\]{--tw-shadow:0 2px 3px 0 var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_2px_8px_rgba\(0\,0\,0\,0\.08\)\]{--tw-shadow:0 2px 8px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_2px_8px_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0 2px 8px var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_2px_10px\]{--tw-shadow:0 2px 10px var(--tw-shadow-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_2px_10px_rgba\(0\,0\,0\,0\.06\)\]{--tw-shadow:0 2px 10px var(--tw-shadow-color,#0000000f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_3\.095px_10\.318px_0_rgba\(0\,0\,0\,0\.05\)\,0_10\.318px_18\.573px_0_rgba\(0\,0\,0\,0\.08\)\]{--tw-shadow:0 3.095px 10.318px 0 var(--tw-shadow-color,#0000000d),0 10.318px 18.573px 0 var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_4px_12px_rgba\(0\,0\,0\,0\.2\)\]{--tw-shadow:0 4px 12px var(--tw-shadow-color,#0003);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_4px_12px_rgba\(0\,0\,0\,0\.16\)\]{--tw-shadow:0 4px 12px var(--tw-shadow-color,#00000029);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_4px_16px_0_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 4px 16px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_4px_16px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 4px 16px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_4px_24px_-5px_rgba\(0\,0\,0\,0\.2\)\]{--tw-shadow:0 4px 24px -5px var(--tw-shadow-color,#0003);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_4px_25px_-5px_rgba\(232\,235\,255\,0\.57\)\]{--tw-shadow:0 4px 25px -5px var(--tw-shadow-color,#e8ebff91);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_4px_64px_0_rgba\(0\,0\,0\,0\.2\)\]{--tw-shadow:0 4px 64px 0 var(--tw-shadow-color,#0003);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_5px_8px_3px_rgba\(0\,0\,0\,0\.05\)\,_0_0\.5px_1px_0px_rgba\(0\,0\,0\,0\.09\)\]{--tw-shadow:0 5px 8px 3px var(--tw-shadow-color,#0000000d),0 .5px 1px 0px var(--tw-shadow-color,#00000017);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_5px_8px_3px_rgba\(0\,0\,0\,0\.025\)\,_0_0\.5px_1px_0px_rgba\(0\,0\,0\,0\.045\)\]{--tw-shadow:0 5px 8px 3px var(--tw-shadow-color,#00000006),0 .5px 1px 0px var(--tw-shadow-color,#0000000b);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_8px_12px_0_rgba\(0\,0\,0\,0\.16\)\,0_0_1px_0_rgba\(0\,0\,0\,0\.60\)\]{--tw-shadow:0 8px 12px 0 var(--tw-shadow-color,#00000029),0 0 1px 0 var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_8px_24px_0_rgba\(0\,0\,0\,0\.18\)\]{--tw-shadow:0 8px 24px 0 var(--tw-shadow-color,#0000002e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_8px_24px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 8px 24px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_8px_24px_rgba\(0\,0\,0\,0\.06\)\]{--tw-shadow:0 8px 24px var(--tw-shadow-color,#0000000f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_8px_30px_0_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0 8px 30px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_10px_24px_rgba\(97\,87\,255\,0\.28\)\]{--tw-shadow:0 10px 24px var(--tw-shadow-color,#6157ff47);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_10px_30px_rgba\(0\,0\,0\,0\.35\)\]{--tw-shadow:0 10px 30px var(--tw-shadow-color,#00000059);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_12px_24px_-6px_rgb\(0_0_0_\/_0\.1\)\,0_0_1px_rgb\(0_0_0_\/_0\.2\)\]{--tw-shadow:0 12px 24px -6px var(--tw-shadow-color,#0000001a),0 0 1px var(--tw-shadow-color,#0003);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_12px_32px_rgba\(15\,23\,42\,0\.08\)\]{--tw-shadow:0 12px 32px var(--tw-shadow-color,#0f172a14);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_12px_36px_rgba\(0\,0\,0\,0\.06\)\]{--tw-shadow:0 12px 36px var(--tw-shadow-color,#0000000f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_12px_40px_rgba\(0\,0\,0\,0\.18\)\]{--tw-shadow:0 12px 40px var(--tw-shadow-color,#0000002e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_14px_62px_0_rgba\(0\,0\,0\,0\.25\)\]{--tw-shadow:0 14px 62px 0 var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_16px_30px_-26px_rgba\(15\,23\,42\,0\.45\)\]{--tw-shadow:0 16px 30px -26px var(--tw-shadow-color,#0f172a73);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_16px_32px_-24px_rgba\(15\,23\,42\,0\.25\)\]{--tw-shadow:0 16px 32px -24px var(--tw-shadow-color,#0f172a40);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_16px_40px_rgba\(0\,0\,0\,0\.22\)\]{--tw-shadow:0 16px 40px var(--tw-shadow-color,#00000038);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_18px_50px_rgba\(0\,0\,0\,0\.08\)\]{--tw-shadow:0 18px 50px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_19px_54px_rgba\(0\,0\,0\,0\.08\)\]{--tw-shadow:0 19px 54px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_19px_54px_rgba\(52\,168\,83\,0\.10\)\]{--tw-shadow:0 19px 54px var(--tw-shadow-color,#34a8531a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_19px_54px_rgba\(66\,133\,244\,0\.10\)\]{--tw-shadow:0 19px 54px var(--tw-shadow-color,#4285f41a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_19px_54px_rgba\(255\,67\,67\,0\.08\)\]{--tw-shadow:0 19px 54px var(--tw-shadow-color,#ff434314);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_20px_25px_-5px_rgba\(0\,0\,0\,0\.1\)\,0_8px_10px_-6px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_20px_48px_rgba\(15\,23\,42\,0\.18\)\]{--tw-shadow:0 20px 48px var(--tw-shadow-color,#0f172a2e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_20px_60px_rgba\(0\,0\,0\,0\.55\)\]{--tw-shadow:0 20px 60px var(--tw-shadow-color,#0000008c);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_24px_64px_rgba\(0\,0\,0\,0\.28\)\]{--tw-shadow:0 24px 64px var(--tw-shadow-color,#00000047);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_24px_80px_rgba\(0\,0\,0\,0\.65\)\]{--tw-shadow:0 24px 80px var(--tw-shadow-color,#000000a6);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_28px_80px_rgba\(0\,0\,0\,0\.18\)\]{--tw-shadow:0 28px 80px var(--tw-shadow-color,#0000002e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_30px_80px_rgba\(0\,0\,0\,0\.6\)\]{--tw-shadow:0 30px 80px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_32px_48px_rgba\(0\,0\,0\,0\.175\)\,_0_0_1px_rgba\(0\,0\,0\,0\.2\)\]{--tw-shadow:0 32px 48px var(--tw-shadow-color,#0000002d),0 0 1px var(--tw-shadow-color,#0003);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_0\.5px_1px_0px_rgba\(0\,0\,0\,0\.09\)\,0px_5px_8px_0px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0px .5px 1px 0px var(--tw-shadow-color,#00000017),0px 5px 8px 0px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_0\.25px_0\.25px_0px_rgba\(0\,0\,0\,0\.12\)\,0px_0\.5px_0\.5px_0px_rgba\(0\,0\,0\,0\.04\)\]{--tw-shadow:0px .25px .25px 0px var(--tw-shadow-color,#0000001f),0px .5px .5px 0px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_0px_0px_1px_rgba\(0\,0\,0\,0\.07\)\,0px_4px_80px_rgba\(0\,0\,0\,0\.02\)\]{--tw-shadow:0px 0px 0px 1px var(--tw-shadow-color,#00000012),0px 4px 80px var(--tw-shadow-color,#00000005);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_0px_0px_1px_rgba\(0\,0\,0\,0\.08\)\,0px_2px_2px_rgba\(0\,0\,0\,0\.08\)\,0px_4px_80px_rgba\(0\,0\,0\,0\.03\)\]{--tw-shadow:0px 0px 0px 1px var(--tw-shadow-color,#00000014),0px 2px 2px var(--tw-shadow-color,#00000014),0px 4px 80px var(--tw-shadow-color,#00000008);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_0px_0px_1px_var\(--border-heavy\)\,0px_4px_12px_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0px 0px 0px 1px var(--tw-shadow-color,var(--border-heavy)),0px 4px 12px var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_0px_0px_1px_var\(--border-heavy\)\,0px_6px_20px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0px 0px 0px 1px var(--tw-shadow-color,var(--border-heavy)),0px 6px 20px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_0px_32px_rgba\(0\,0\,0\,0\.08\)\]{--tw-shadow:0px 0px 32px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_1px_1px_0px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0px 1px 1px 0px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_1px_1px_0px_var\(--shadow-color-1\,rgba\(0\,_0\,_0\,_0\.06\)\)\,0px_0px_1px_0px_var\(--shadow-color-2\,rgba\(0\,_0\,_0\,_0\.3\)\)\]{--tw-shadow:0px 1px 1px 0px var(--tw-shadow-color,var(--shadow-color-1,#0000000f)),0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#0000004d));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_1px_3px_rgba\(0\,0\,0\,0\.04\)\,0px_0px_0px_0\.5px_var\(--border-light\)\]{--tw-shadow:0px 1px 3px var(--tw-shadow-color,#0000000a),0px 0px 0px .5px var(--tw-shadow-color,var(--border-light));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_2px_12px_rgba\(0\,0\,0\,0\.06\)\]{--tw-shadow:0px 2px 12px var(--tw-shadow-color,#0000000f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_2px_14px_0px_rgba\(0\,0\,0\,0\.04\)\]{--tw-shadow:0px 2px 14px 0px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_4px_6px_-4px_rgba\(0\,0\,0\,0\.1\)\,0px_10px_15px_-3px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0px 4px 6px -4px var(--tw-shadow-color,#0000001a),0px 10px 15px -3px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_4px_14px_rgba\(0\,0\,0\,0\.06\)\]{--tw-shadow:0px 4px 14px var(--tw-shadow-color,#0000000f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_4px_16px_0px_rgba\(0\,0\,0\,0\.05\)\],.shadow-\[0px_4px_16px_0px_rgba\(0\,_0\,_0\,_0\.05\)\]{--tw-shadow:0px 4px 16px 0px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_4px_16px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0px 4px 16px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_6px_18px_rgba\(0\,0\,0\,0\.07\)\]{--tw-shadow:0px 6px 18px var(--tw-shadow-color,#00000012);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_6px_24px_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0px 6px 24px var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_8px_14px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:0px 8px 14px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_8px_32px_rgba\(0\,0\,0\,0\.06\)\]{--tw-shadow:0px 8px 32px var(--tw-shadow-color,#0000000f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_10px_20px_0px_rgb\(0\,0\,0\,0\.25\)\]{--tw-shadow:0px 10px 20px 0px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_10px_30px_0px\]{--tw-shadow:0px 10px 30px 0px var(--tw-shadow-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_12px_32px_rgba\(15\,23\,42\,0\.14\)\]{--tw-shadow:0px 12px 32px var(--tw-shadow-color,#0f172a24);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_16px_48px_rgba\(0\,0\,0\,0\.16\)\]{--tw-shadow:0px 16px 48px var(--tw-shadow-color,#00000029);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_20px_25\.000001907348633px_-5px_rgba\(0\,0\,0\,0\.10\)\]{--tw-shadow:0px 20px 25px -5px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_20px_25px_-5px_rgba\(0\,0\,0\,0\.1\)\,0px_8px_10px_-6px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0px 20px 25px -5px var(--tw-shadow-color,#0000001a),0px 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_20px_25px_-5px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0px 20px 25px -5px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0px_20px_25px_0px_rgba\(0\,0\,0\,0\.10\)\,0px_8px_10px_0px_rgba\(0\,0\,0\,0\.10\)\]{--tw-shadow:0px 20px 25px 0px var(--tw-shadow-color,#0000001a),0px 8px 10px 0px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[4px_0_0_0_var\(--bg-primary\)\]{--tw-shadow:4px 0 0 0 var(--tw-shadow-color,var(--bg-primary));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0_0_0_1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0_0_0_1px_rgba\(0\,0\,0\,0\.05\)\]{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0_0_0_1px_var\(--alpha-08\)\]{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,var(--alpha-08));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0_0_30px_rgba\(0\,0\,0\,0\.08\)\]{--tw-shadow:inset 0 0 30px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0_1px_0_rgba\(255\,255\,255\,0\.18\)\]{--tw-shadow:inset 0 1px 0 var(--tw-shadow-color,#ffffff2e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0px_0px_1px_rgba\(0\,0\,0\,0\.5\)\]{--tw-shadow:inset 0px 0px 1px var(--tw-shadow-color,#00000080);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[none\]{--tw-shadow:none;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[var\(--sidechat-shadow\)\]\!{--tw-shadow:var(--sidechat-shadow)!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow-elevation-01{--tw-shadow:0px 4px 16px 0px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-elevation-01\!{--tw-shadow:0px 4px 16px 0px var(--tw-shadow-color,#0000000d)!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow-elevation-03{--tw-shadow:0px 20px 25px -5px var(--tw-shadow-color,#0000001a),0px 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 transparent;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-none\!{--tw-shadow:0 0 transparent!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 0 15px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xxs{--tw-shadow:0 0 2px 0 var(--tw-shadow-color,#0000000d),0 4px 6px 0 var(--tw-shadow-color,#00000005);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0\!{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor)!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.sharp-edge-top-shadow{box-shadow:var(--sharp-edge-top-shadow-placeholder)}.sharp-edge-top-shadow[data-scrolled-from-start]{box-shadow:var(--sharp-edge-top-shadow)}.\[box-shadow\:0_2px_8px_0_rgba\(0\,0\,0\,0\.05\)\]{box-shadow:0 2px 8px #0000000d}.\[box-shadow\:none\]{box-shadow:none}.\[box-shadow\:var\(--sharp-edge-bottom-shadow\)\]{box-shadow:var(--sharp-edge-bottom-shadow)}.\[box-shadow\:var\(--sharp-edge-bottom-shadow-placeholder\)\]{box-shadow:var(--sharp-edge-bottom-shadow-placeholder)}.\[box-shadow\:var\(--sharp-edge-top-shadow\)\]{box-shadow:var(--sharp-edge-top-shadow)}.\[box-shadow\:var\(--sharp-edge-top-shadow-placeholder\)\]{box-shadow:var(--sharp-edge-top-shadow-placeholder)}.shadow-black\/3{--tw-shadow-color:#00000008}@supports (color:color-mix(in lab, red, red)){.shadow-black\/3{--tw-shadow-color:color-mix(in oklab,lab(0% 0 0/.03) var(--tw-shadow-alpha),transparent)}}.shadow-black\/5{--tw-shadow-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.shadow-black\/5{--tw-shadow-color:color-mix(in oklab,lab(0% 0 0/.05) var(--tw-shadow-alpha),transparent)}}.shadow-black\/20{--tw-shadow-color:#0003}@supports (color:color-mix(in lab, red, red)){.shadow-black\/20{--tw-shadow-color:color-mix(in oklab,lab(0% 0 0/.2) var(--tw-shadow-alpha),transparent)}}.shadow-token-border-default{--tw-shadow-color:var(--border-default)}@supports (color:color-mix(in lab, red, red)){.shadow-token-border-default{--tw-shadow-color:color-mix(in oklab,var(--border-default)var(--tw-shadow-alpha),transparent)}}.shadow-token-border-heavy{--tw-shadow-color:var(--border-heavy)}@supports (color:color-mix(in lab, red, red)){.shadow-token-border-heavy{--tw-shadow-color:color-mix(in oklab,var(--border-heavy)var(--tw-shadow-alpha),transparent)}}.shadow-token-border-light{--tw-shadow-color:var(--border-light)}@supports (color:color-mix(in lab, red, red)){.shadow-token-border-light{--tw-shadow-color:color-mix(in oklab,var(--border-light)var(--tw-shadow-alpha),transparent)}}.shadow-token-main-surface-primary{--tw-shadow-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.shadow-token-main-surface-primary{--tw-shadow-color:color-mix(in oklab,var(--main-surface-primary)var(--tw-shadow-alpha),transparent)}}.ring-black{--tw-ring-color:#000}.ring-black\/0{--tw-ring-color:transparent}@supports (color:lab(0% 0 0)){.ring-black\/0{--tw-ring-color:lab(0% 0 0/0)}}.ring-black\/5{--tw-ring-color:#0000000d}@supports (color:lab(0% 0 0)){.ring-black\/5{--tw-ring-color:lab(0% 0 0/.05)}}.ring-black\/10{--tw-ring-color:#0000001a}@supports (color:lab(0% 0 0)){.ring-black\/10{--tw-ring-color:lab(0% 0 0/.1)}}.ring-black\/\[0\.05\]{--tw-ring-color:#0000000d}@supports (color:lab(0% 0 0)){.ring-black\/\[0\.05\]{--tw-ring-color:lab(0% 0 0/.05)}}.ring-black\/\[0\.08\]{--tw-ring-color:#00000014}@supports (color:lab(0% 0 0)){.ring-black\/\[0\.08\]{--tw-ring-color:lab(0% 0 0/.08)}}.ring-black\/\[0\.025\]{--tw-ring-color:#00000006}@supports (color:lab(0% 0 0)){.ring-black\/\[0\.025\]{--tw-ring-color:lab(0% 0 0/.025)}}.ring-blue-200{--tw-ring-color:var(--blue-200)}.ring-blue-400,.ring-blue-400\/40{--tw-ring-color:var(--blue-400)}@supports (color:color-mix(in lab, red, red)){.ring-blue-400\/40{--tw-ring-color:color-mix(in oklab,var(--blue-400)40%,transparent)}}.ring-blue-500,.ring-blue-500\/60{--tw-ring-color:var(--blue-500)}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/60{--tw-ring-color:color-mix(in oklab,var(--blue-500)60%,transparent)}}.ring-gray-200{--tw-ring-color:var(--gray-200)}.ring-green-200{--tw-ring-color:var(--green-200)}.ring-red-200{--tw-ring-color:var(--red-200)}.ring-red-500{--tw-ring-color:var(--red-500)}.ring-token-border-light,.ring-token-border-light\/60{--tw-ring-color:var(--border-light)}@supports (color:color-mix(in lab, red, red)){.ring-token-border-light\/60{--tw-ring-color:color-mix(in oklab,var(--border-light)60%,transparent)}}.ring-token-border-xheavy{--tw-ring-color:var(--border-xheavy)}.ring-token-text-primary,.ring-token-text-primary\/12{--tw-ring-color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.ring-token-text-primary\/12{--tw-ring-color:color-mix(in oklab,var(--text-primary)12%,transparent)}}.ring-transparent{--tw-ring-color:transparent}.ring-white{--tw-ring-color:#fff}.ring-white\/20{--tw-ring-color:#fff3}@supports (color:lab(0% 0 0)){.ring-white\/20{--tw-ring-color:lab(100% -.0000298023 .0000119209/.2)}}.ring-white\/70{--tw-ring-color:#ffffffb3}@supports (color:lab(0% 0 0)){.ring-white\/70{--tw-ring-color:lab(100% -.0000298023 .0000119209/.7)}}.ring-offset-0{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ring-offset-4{--tw-ring-offset-width:4px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ring-offset-black{--tw-ring-offset-color:#000}.ring-offset-transparent{--tw-ring-offset-color:transparent}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.outline-0{outline-style:var(--tw-outline-style);outline-width:0}.outline-0\!{outline-style:var(--tw-outline-style)!important;outline-width:0!important}.outline-1{outline-style:var(--tw-outline-style);outline-width:1px}.outline-2{outline-style:var(--tw-outline-style);outline-width:2px}.outline-\[0\.5px\]{outline-style:var(--tw-outline-style);outline-width:.5px}.outline-\[1px\]{outline-style:var(--tw-outline-style);outline-width:1px}.-outline-offset-2{outline-offset:calc(2px*-1)}.-outline-offset-\[1\.5px\]{outline-offset:calc(1.5px*-1)}.outline-offset-1{outline-offset:1px}.outline-offset-2{outline-offset:2px}.outline-offset-4{outline-offset:4px}.outline-offset-\[-1px\]{outline-offset:-1px}.outline-\(--interactive-label-accent-default\,var\(--bg-primary-inverted\)\){outline-color:var(--interactive-label-accent-default,var(--bg-primary-inverted))}.outline-\[\#5856D6\]{outline-color:#5856d6}.outline-black\/5{outline-color:#0000000d;outline-color:lab(0% 0 0/.05)}.outline-black\/10{outline-color:#0000001a;outline-color:lab(0% 0 0/.1)}.outline-blue-400{outline-color:var(--blue-400)}.outline-green-400{outline-color:var(--green-400)}.outline-orange-400{outline-color:var(--orange-400)}.outline-orange-500{outline-color:var(--orange-500)}.outline-pink-400{outline-color:var(--pink-400)}.outline-purple-400{outline-color:var(--purple-400)}.outline-red-400{outline-color:var(--red-400)}.outline-token-bg-primary{outline-color:var(--bg-primary)}.outline-token-border-default{outline-color:var(--border-default)}.outline-token-border-heavy{outline-color:var(--border-heavy)}.outline-token-border-light{outline-color:var(--border-light)}.outline-token-border-status-warning{outline-color:var(--border-status-warning)}.outline-token-border-xlight{outline-color:var(--border-xlight)}.outline-token-icon-secondary{outline-color:var(--icon-secondary)}.outline-token-interactive-label-accent-default{outline-color:var(--interactive-label-accent-default)}.outline-token-text-primary{outline-color:var(--text-primary)}.outline-transparent{outline-color:#0000}.outline-white{outline-color:#fff}.outline-yellow-400{outline-color:var(--yellow-400)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-2xl{--tw-blur:blur(var(--blur-2xl));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-3xl{--tw-blur:blur(var(--blur-3xl));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-\[0\.561px\]{--tw-blur:blur(.561px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-\[1\.5px\]{--tw-blur:blur(1.5px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-\[1px\]{--tw-blur:blur(1px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-\[10px\]{--tw-blur:blur(10px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-\[15px\]{--tw-blur:blur(15px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-\[100px\]{--tw-blur:blur(100px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-lg{--tw-blur:blur(var(--blur-lg));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-md{--tw-blur:blur(var(--blur-md));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-sm{--tw-blur:blur(var(--blur-sm));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.brightness-0{--tw-brightness:brightness(0%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.brightness-90{--tw-brightness:brightness(90%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-2xl{--tw-drop-shadow-size:drop-shadow(0 25px 25px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-2xl));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-\[0_0_6px_rgba\(0\,0\,0\,0\.85\)\]{--tw-drop-shadow-size:drop-shadow(0 0 6px var(--tw-drop-shadow-color,#000000d9));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-\[0_0_14px_rgba\(0\,0\,0\,0\.45\)\]{--tw-drop-shadow-size:drop-shadow(0 0 14px var(--tw-drop-shadow-color,#00000073));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-\[0_1px_2px_rgba\(0\,0\,0\,0\.35\)\]{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#00000059));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-\[0_1px_2px_rgba\(0\,0\,0\,0\.45\)\]{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#00000073));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-\[0_3\.84px_23\.04px_rgba\(0\,0\,0\,0\.06\)\]{--tw-drop-shadow-size:drop-shadow(0 3.84px 23.04px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-\[0_6px_4px_var\(--shadow-color\)\]{--tw-drop-shadow-size:drop-shadow(0 6px 4px var(--tw-drop-shadow-color,var(--shadow-color)));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-\[0_6px_18px_rgba\(0\,0\,0\,0\.35\)\]{--tw-drop-shadow-size:drop-shadow(0 6px 18px var(--tw-drop-shadow-color,#00000059));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-\[0_6px_18px_rgba\(0\,0\,0\,0\.55\)\]{--tw-drop-shadow-size:drop-shadow(0 6px 18px var(--tw-drop-shadow-color,#0000008c));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-\[0_8px_20px_rgba\(0\,0\,0\,0\.18\)\]{--tw-drop-shadow-size:drop-shadow(0 8px 20px var(--tw-drop-shadow-color,#0000002e));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-\[0_12px_32px_rgba\(0\,0\,0\,0\.06\)\]{--tw-drop-shadow-size:drop-shadow(0 12px 32px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-\[0_20px_50px_rgba\(190\,210\,233\,0\.75\)\]{--tw-drop-shadow-size:drop-shadow(0 20px 50px var(--tw-drop-shadow-color,#bed2e9bf));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-lg{--tw-drop-shadow-size:drop-shadow(0 4px 4px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-lg));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-md{--tw-drop-shadow-size:drop-shadow(0 3px 3px var(--tw-drop-shadow-color,#0000001f));--tw-drop-shadow:drop-shadow(var(--drop-shadow-md));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-sm{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-sm));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-xl{--tw-drop-shadow-size:drop-shadow(0 9px 7px var(--tw-drop-shadow-color,#0000001a));--tw-drop-shadow:drop-shadow(var(--drop-shadow-xl));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-xs{--tw-drop-shadow-size:drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000d));--tw-drop-shadow:drop-shadow(var(--drop-shadow-xs));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.grayscale-\[0\.6\]{--tw-grayscale:grayscale(.6);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.\[filter\:drop-shadow\(0_0_0_currentColor\)\]{filter:drop-shadow(0 0)}.\[filter\:drop-shadow\(0px_20px_18px_rgba\(0\,0\,0\,0\.1\)\)_drop-shadow\(0px_8px_5px_rgba\(0\,0\,0\,0\.08\)\)\]{filter:drop-shadow(0 20px 18px #0000001a)drop-shadow(0 8px 5px #00000014)}.\[filter\:grayscale\(1\)_brightness\(0\)\]{filter:grayscale()brightness(0)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-2xl{--tw-backdrop-blur:blur(var(--blur-2xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-2xl\!{--tw-backdrop-blur:blur(var(--blur-2xl))!important;-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)!important;backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)!important}.backdrop-blur-3xl{--tw-backdrop-blur:blur(var(--blur-3xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-\[1\.5px\]{--tw-backdrop-blur:blur(1.5px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-\[10px\]{--tw-backdrop-blur:blur(10px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-\[12px\]{--tw-backdrop-blur:blur(12px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-\[20px\]{--tw-backdrop-blur:blur(20px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-\[24px\]{--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-\[27px\]{--tw-backdrop-blur:blur(27px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-\[38px\]{--tw-backdrop-blur:blur(38px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-\[40px\]{--tw-backdrop-blur:blur(40px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-\[70px\]{--tw-backdrop-blur:blur(70px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-lg{--tw-backdrop-blur:blur(var(--blur-lg));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-none{--tw-backdrop-blur: ;-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-xl{--tw-backdrop-blur:blur(var(--blur-xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-brightness-75{--tw-backdrop-brightness:brightness(75%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-contrast-125{--tw-backdrop-contrast:contrast(125%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-saturate-25{--tw-backdrop-saturate:saturate(25%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-saturate-100{--tw-backdrop-saturate:saturate(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-saturate-125{--tw-backdrop-saturate:saturate(125%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.\[backdrop-filter\:var\(--sidebar-sticky-backdrop\)\]{-webkit-backdrop-filter:var(--sidebar-sticky-backdrop);backdrop-filter:var(--sidebar-sticky-backdrop)}.transition{transition-property:color,background-color,border-color,outline-color,-webkit-text-decoration-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[backdrop-filter\]{transition-property:-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,backdrop-filter\,opacity\]{transition-property:background-color,-webkit-backdrop-filter,backdrop-filter,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,backdrop-filter\]{transition-property:background-color,-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,border-color\]{transition-property:background-color,border-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,box-shadow\]{transition-property:background-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[background-color\,transform\]{transition-property:background-color,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[border-color\]{transition-property:border-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[box-shadow\,background-color\]{transition-property:box-shadow,background-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[box-shadow\]{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[filter\,opacity\]{transition-property:filter,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[filter\,transform\]{transition-property:filter,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[filter\]{transition-property:filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[flex-basis\]{transition-property:flex-basis;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[grid-template-rows\,opacity\,margin\]{transition-property:grid-template-rows,opacity,margin;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[grid-template-rows\,padding-bottom\,opacity\]{transition-property:grid-template-rows,padding-bottom,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[height\]{transition-property:height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\,right\,transform\]{transition-property:left,right,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[margin\,color\]{transition-property:margin,color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[mask\]{transition-property:-webkit-mask,mask;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[max-height\,opacity\,transform\]{transition-property:max-height,opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[max-height\,opacity\]{transition-property:max-height,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,background-color\,border-color\,box-shadow\]{transition-property:opacity,background-color,border-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,background\]{transition-property:opacity,background;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,display\,backdrop-filter\]{transition-property:opacity,display,-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,display\]{transition-property:opacity,display;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,filter\]{transition-property:opacity,filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,transform\]{transition-property:opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\]{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity_transform\]{transition-property:opacity transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[stroke-dashoffset\]{transition-property:stroke-dashoffset;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[transform\,opacity\]{transition-property:transform,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[transform_--shadow-color\]{transition-property:transform --shadow-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[transform_opacity\]{transition-property:transform opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\,border-color\,box-shadow\,background-color\]{transition-property:width,border-color,box-shadow,background-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\,opacity\,transform\]{transition-property:width,opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,-webkit-text-decoration-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-width{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.\[transition-property\:translate\,opacity\]{transition-property:translate,opacity}.transition-none{transition-property:none}.transition-none\!{transition-property:none!important}.transition-discrete{transition-behavior:allow-discrete}.delay-0{transition-delay:0s}.delay-75{transition-delay:75ms}.delay-100{transition-delay:.1s}.delay-200{transition-delay:.2s}.duration-0{--tw-duration:0s;transition-duration:0s}.duration-50{--tw-duration:50ms;transition-duration:50ms}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-120{--tw-duration:.12s;transition-duration:.12s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-175{--tw-duration:.175s;transition-duration:.175s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-220{--tw-duration:.22s;transition-duration:.22s}.duration-250{--tw-duration:.25s;transition-duration:.25s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-350{--tw-duration:.35s;transition-duration:.35s}.duration-400{--tw-duration:.4s;transition-duration:.4s}.duration-450{--tw-duration:.45s;transition-duration:.45s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-600{--tw-duration:.6s;transition-duration:.6s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.duration-800{--tw-duration:.8s;transition-duration:.8s}.duration-1000{--tw-duration:1s;transition-duration:1s}.duration-2000{--tw-duration:2s;transition-duration:2s}.duration-\[0\.24s\]{--tw-duration:.24s;transition-duration:.24s}.duration-\[1\.5s\]{--tw-duration:1.5s;transition-duration:1.5s}.duration-\[400ms\]{--tw-duration:.4s;transition-duration:.4s}.duration-\[420ms\]{--tw-duration:.42s;transition-duration:.42s}.duration-\[700ms\]{--tw-duration:.7s;transition-duration:.7s}.ease-\[cubic-bezier\(\.24\,\.1\,\.42\,\.91\)\]{--tw-ease:cubic-bezier(.24,.1,.42,.91);transition-timing-function:cubic-bezier(.24,.1,.42,.91)}.ease-\[cubic-bezier\(0\.17\,0\.17\,0\.30\,1\.00\)\]{--tw-ease:cubic-bezier(.17,.17,.3,1);transition-timing-function:cubic-bezier(.17,.17,.3,1)}.ease-\[cubic-bezier\(0\.22\,1\,0\.36\,1\)\]{--tw-ease:cubic-bezier(.22,1,.36,1);transition-timing-function:cubic-bezier(.22,1,.36,1)}.ease-\[cubic-bezier\(0\.87\,_0\,_0\.13\,_1\)\]{--tw-ease:cubic-bezier(.87,0,.13,1);transition-timing-function:cubic-bezier(.87,0,.13,1)}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.ease-spring-fast{--tw-ease:var(--spring-fast);transition-timing-function:var(--spring-fast)}.ease-spring-standard{--tw-ease:var(--spring-common);transition-timing-function:var(--spring-common)}.\[will-change\:transform\]{will-change:transform}.will-change-\[opacity\,clip-path\,transform\]{will-change:opacity,clip-path,transform}.will-change-\[opacity\]{will-change:opacity}.will-change-\[transform\],.will-change-transform{will-change:transform}.contain-inline-size{--tw-contain-size:inline-size;contain:var(--tw-contain-size,)var(--tw-contain-layout,)var(--tw-contain-paint,)var(--tw-contain-style,)}.contain-layout{--tw-contain-layout:layout;contain:var(--tw-contain-size,)var(--tw-contain-layout,)var(--tw-contain-paint,)var(--tw-contain-style,)}.contain-paint{--tw-contain-paint:paint;contain:var(--tw-contain-size,)var(--tw-contain-layout,)var(--tw-contain-paint,)var(--tw-contain-style,)}.contain-size{--tw-contain-size:size;contain:var(--tw-contain-size,)var(--tw-contain-layout,)var(--tw-contain-paint,)var(--tw-contain-style,)}.contain-style{--tw-contain-style:style;contain:var(--tw-contain-size,)var(--tw-contain-layout,)var(--tw-contain-paint,)var(--tw-contain-style,)}.contain-\[style_inline-size\]{contain:style inline-size}.contain-content{contain:content}.contain-strict{contain:strict}.peek-top-animation{animation-name:peek-top-animation;animation-duration:2s;animation-iteration-count:1;animation-delay:1s;animation-direction:forward;animation-timing-function:var(--easing-spring-elegant)}.peek-top-end-animation{animation-name:peek-top-end-animation;animation-duration:.1s;animation-timing-function:var(--easing-spring-elegant)}.outline-none{--tw-outline-style:none;outline-style:none}.outline-none\!{--tw-outline-style:none!important;outline-style:none!important}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.select-text{-webkit-user-select:text;user-select:text}.\[--bg\:red\]{--bg:red}.\[--btn-background-color\:var\(--bg-tertiary\)\]{--btn-background-color:var(--bg-tertiary)}.\[--btn-text-color\:var\(--text-secondary\)\]{--btn-text-color:var(--text-secondary)}.\[--canvas-bg\:var\(--bg-primary\)\]{--canvas-bg:var(--bg-primary)}.\[--codemirror-bg\:var\(--bg-primary\)\]{--codemirror-bg:var(--bg-primary)}.\[--composer-container-height\:auto\]{--composer-container-height:auto}.\[--composer-overlap-px\:28px\]{--composer-overlap-px:28px}.\[--composer-overlap-px\:55px\]{--composer-overlap-px:55px}.\[--constant-background-active\:rgba\(0\,0\,0\,0\.08\)\]{--constant-background-active:#00000014}.\[--constant-background\:rgba\(0\,0\,0\,0\.04\)\]{--constant-background:#0000000a}.\[--content-fade-height\:110px\]{--content-fade-height:110px}.\[--content-fade-height\:130px\]{--content-fade-height:130px}.\[--content-fade-top\:-15px\]{--content-fade-top:-15px}.\[--content-fade-top\:-40px\]{--content-fade-top:-40px}.\[--content-fade-top\:-110px\]{--content-fade-top:-110px}.\[--content-fade-top\:-130px\]{--content-fade-top:-130px}.\[--edge-fade-distance\:1rem\]{--edge-fade-distance:1rem}.\[--end\:right\]{--end:right}.\[--file-tile-action-size\:1\.5rem\]{--file-tile-action-size:1.5rem}.\[--file-tile-action-size\:16px\]{--file-tile-action-size:16px}.\[--file-tile-image-size\:3\.625rem\]{--file-tile-image-size:3.625rem}.\[--file-tile-width\:12\.75rem\]{--file-tile-width:12.75rem}.\[--file-tile-width\:15rem\]{--file-tile-width:15rem}.\[--file-tile-width\:20rem\]{--file-tile-width:20rem}.\[--file-tile-width\:56px\]{--file-tile-width:56px}.\[--focus-outline-margin\:-4px\]{--focus-outline-margin:-4px}.\[--header-height\:0\]{--header-height:0}.\[--image-page-spacing\:44px\]{--image-page-spacing:44px}.\[--images-app-padding\:16px\]{--images-app-padding:16px}.\[--padding\:1rem\]{--padding:1rem}.\[--panel-header-height\:var\(--screen-thread-header-min-height\)\]{--panel-header-height:var(--screen-thread-header-min-height)}.\[--ratio\:4\/3\]{--ratio:4/3}.\[--right-bg\:var\(--bg-primary\)\]{--right-bg:var(--bg-primary)}.\[--right-bg\:var\(--bg-tertiary\)\]{--right-bg:var(--bg-tertiary)}.\[--shadow-color\:transparent\]{--shadow-color:transparent}.\[--shadow-height\:45px\]{--shadow-height:45px}.\[--sheet-radius-amount\:16px\]{--sheet-radius-amount:16px}.\[--single-line-fade-height\:32px\]{--single-line-fade-height:32px}.\[--skeleton-opacity\:0\.3\]{--skeleton-opacity:.3}.\[--skeleton-opacity\:0\.75\]{--skeleton-opacity:.75}.\[--start\:left\]{--start:left}.\[--sticky-padding-top\:0px\]{--sticky-padding-top:0px}.\[--sticky-padding-top\:var\(--header-height\)\]{--sticky-padding-top:var(--header-height)}.\[--sticky-spacer\:6px\]{--sticky-spacer:6px}.\[--text-lg--line-height\:28px\]{--text-lg--line-height:28px}.\[--text-lg\:22px\]{--text-lg:22px}.\[--thread-component-gap\:28px\]{--thread-component-gap:28px}.\[--thread-content-margin-xs\:calc\(var\(--spacing\)\*3\)\]{--thread-content-margin-xs:calc(var(--spacing)*3)}.\[--thread-content-margin\:var\(--thread-content-margin-xs\,calc\(var\(--spacing\)\*4\)\)\]{--thread-content-margin:var(--thread-content-margin-xs,calc(var(--spacing)*4))}.\[--thread-content-max-width\:40rem\]{--thread-content-max-width:40rem}.\[--thread-content-max-width\:44rem\]{--thread-content-max-width:44rem}.\[--thread-content-max-width\:100\%\]{--thread-content-max-width:100%}.\[--trigger-width\:calc\(var\(--radix-dropdown-menu-trigger-width\)-2\*var\(--radix-align-offset\)\)\]{--trigger-width:calc(var(--radix-dropdown-menu-trigger-width) - 2*var(--radix-align-offset))}.\[--true-content-width\:calc\(var\(--thread-content-max-width\)\+6rem\)\]{--true-content-width:calc(var(--thread-content-max-width) + 6rem)}.\[-ms-overflow-style\:none\]{-ms-overflow-style:none}.\[-webkit-mask-composite\:source-in\]{-webkit-mask-composite:source-in}.\[-webkit-mask-image\:linear-gradient\(180deg\,transparent_33\.62\%\,black_100\%\)\]{-webkit-mask-image:linear-gradient(#0000 33.62%,#000 100%)}.\[-webkit-mask-image\:linear-gradient\(black\,transparent_80\%\)\]{-webkit-mask-image:linear-gradient(#000,#0000 80%)}.\[-webkit-mask-image\:linear-gradient\(to_bottom\,rgba\(0\,0\,0\,1\)_-300px\,rgba\(0\,0\,0\,0\)_300px\)\]{-webkit-mask-image:linear-gradient(#000 -300px,#0000 300px)}.\[-webkit-mask-image\:linear-gradient\(to_bottom\,rgba\(0\,0\,0\,1\)_20\%\,rgba\(0\,0\,0\,0\)_100\%\)\]{-webkit-mask-image:linear-gradient(#000 20%,#0000 100%)}.\[-webkit-mask-image\:linear-gradient\(to_right\,black_85\%\,transparent_100\%\)\]{-webkit-mask-image:linear-gradient(90deg,#000 85%,#0000 100%)}.\[-webkit-mask-image\:linear-gradient\(to_right\,black_calc\(100\%_-_1rem\)\,transparent_100\%\)\]{-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - 1rem),#0000 100%)}.\[-webkit-mask-image\:linear-gradient\(to_right\,transparent_0\%\,black_20\%\,black_80\%\,transparent_100\%\)\,linear-gradient\(to_top\,black_0\%\,black_75\%\,transparent_100\%\)\]{-webkit-mask-image:linear-gradient(90deg,#0000 0%,#000 20%,#000 80%,#0000 100%),linear-gradient(#0000 0%,#000 25%,#000 100%)}.\[-webkit-mask-image\:linear-gradient\(to_top\,black\,transparent\)\]{-webkit-mask-image:linear-gradient(#0000,#000)}.\[-webkit-mask-image\:linear-gradient\(to_top_left\,rgba\(0\,0\,0\,0\)_0\%\,rgba\(0\,0\,0\,1\)_30\%\,rgba\(0\,0\,0\,1\)_70\%\,rgba\(0\,0\,0\,0\)_100\%\)\]{-webkit-mask-image:linear-gradient(to top left,#0000 0%,#000 30%,#000 70%,#0000 100%)}.\[-webkit-mask-repeat\:no-repeat\]{-webkit-mask-repeat:no-repeat}.\[-webkit-mask-size\:100\%_100\%\]{-webkit-mask-size:100% 100%}.\[anchor-name\:--carousel\]{anchor-name:--carousel}.\[animation-play-state\:paused\]{animation-play-state:paused}.\[backface-visibility\:hidden\]{-webkit-backface-visibility:hidden;backface-visibility:hidden}.\[background-position-y\:-40vw\]{background-position-y:-40vw}.\[background\:radial-gradient\(circle_at_50\%_50\%\,var\(--blob-color\)_0\%\,transparent_80\%\)\]{background:radial-gradient(circle at 50% 50%,var(--blob-color)0%,transparent 80%)}.\[clip-path\:inset\(56px_0px_0px_0px\)\]{clip-path:inset(56px 0 0)}.\[content-visibility\:auto\]{content-visibility:auto}.\[direction\:ltr\]{direction:ltr}.\[grid-area\:1\/1\]{grid-area:1/1}.\[grid-area\:_title\]{grid-area:title}.\[grid-area\:footer\]{grid-area:footer}.\[grid-area\:header\]{grid-area:header}.\[grid-area\:leading\]{grid-area:leading}.\[grid-area\:primary\]{grid-area:primary}.\[grid-area\:tools\]{grid-area:tools}.\[grid-area\:trailing\]{grid-area:trailing}.\[grid-template-areas\:\'header_header_header\'_\'leading_primary_trailing\'_\'\._footer_\.\'\]{grid-template-areas:"header header header""leading primary trailing"".footer."}.\[grid-template-areas\:\'leading_primary_trailing\'\]{grid-template-areas:"leading primary trailing"}.\[grid-template-areas\:\'primary_primary_primary\'_\'leading_\._trailing\'\]{grid-template-areas:"primary primary primary""leading.trailing"}.\[grid-template-areas\:\'primary_primary_primary\'_\'leading_tools_trailing\'\]{grid-template-areas:"primary primary primary""leading tools trailing"}.\[grid-template-areas\:\'primary_trailing\'\]{grid-template-areas:"primary trailing"}.\[grid-template-areas\:_\'title_action\'_\'description_action\'\]{grid-template-areas:"title action""description action"}.\[interest-delay\:0\.5s_1s\]{interest-delay:.5s 1s}.\[interpolate-size\:allow-keywords\]{interpolate-size:allow-keywords}.\[overflow-anchor\:none\]{overflow-anchor:none}.\[overflow-clip-margin\:4px\]{overflow-clip-margin:4px}.\[overflow-clip-margin\:6px\]{overflow-clip-margin:6px}.\[position-area\:block-end_center\]{position-area:block-end center}.\[position-area\:bottom\]{position-area:bottom}.\[scrollbar-gutter\:stable\]{scrollbar-gutter:stable}.\[scrollbar-gutter\:stable_both-edges\]{scrollbar-gutter:stable both-edges}.\[scrollbar-width\:none\]{scrollbar-width:none}.\[scrollbar-width\:thin\]{scrollbar-width:thin}.\[text-box-trim\:trim-both\]{text-box-trim:trim-both}.\[text-decoration-skip-ink\:none\]{-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}.\[text-shadow\:-0\.8px_-0\.8px_0_\#fff\,0\.8px_-0\.8px_0_\#fff\,-0\.8px_0\.8px_0_\#fff\,0\.8px_0\.8px_0_\#fff\]{text-shadow:-.8px -.8px #fff,.8px -.8px #fff,-.8px .8px #fff,.8px .8px #fff}.\[transition\:opacity_500ms_ease-out\,border-color_200ms_ease-in-out\,background-color_200ms_ease-in-out\]{transition:opacity .5s ease-out,border-color .2s ease-in-out,background-color .2s ease-in-out}.\[view-transition-name\:code-block-code-pane\]{view-transition-name:code-block-code-pane}.\[view-transition-name\:code-block-preview-pane\]{view-transition-name:code-block-preview-pane}.\[view-transition-name\:fullscreen-popover-thread\]{view-transition-name:fullscreen-popover-thread}.\[view-transition-name\:map-with-entities\]{view-transition-name:map-with-entities}.\[view-transition-name\:pinned-kanzi-widget\]{view-transition-name:pinned-kanzi-widget}.\[view-transition-name\:var\(--sidebar-popover\)\]{view-transition-name:var(--sidebar-popover)}.\[view-transition-name\:var\(--vt-active-image\)\]{view-transition-name:var(--vt-active-image)}.\[view-transition-name\:var\(--vt-composer\)\]{view-transition-name:var(--vt-composer)}.\[view-transition-name\:var\(--vt-composer-active-system-hint-pill\)\]{view-transition-name:var(--vt-composer-active-system-hint-pill)}.\[view-transition-name\:var\(--vt-composer-whisper-button\)\]{view-transition-name:var(--vt-composer-whisper-button)}.\[view-transition-name\:var\(--vt-disclaimer\)\]{view-transition-name:var(--vt-disclaimer)}.\[view-transition-name\:var\(--vt-grid-item\)\]{view-transition-name:var(--vt-grid-item)}.\[view-transition-name\:var\(--vt-image-carousel\)\]{view-transition-name:var(--vt-image-carousel)}.\[view-transition-name\:var\(--vt-page-footer\)\]{view-transition-name:var(--vt-page-footer)}.\[view-transition-name\:var\(--vt-page-header\)\]{view-transition-name:var(--vt-page-header)}.\[view-transition-name\:var\(--vt-page-title\)\]{view-transition-name:var(--vt-page-title)}.\[view-transition-name\:var\(--vt-scroll-buttons\)\]{view-transition-name:var(--vt-scroll-buttons)}.\[view-transition-name\:var\(--vt-tool-page-title\)\]{view-transition-name:var(--vt-tool-page-title)}.\[zoom\:0\.96\]{zoom:.96}.corner-superellipse\/0\.98{corner-shape:superellipse(.98)}.corner-superellipse\/1\.1{corner-shape:superellipse(1.1)}.corner-superellipse\/1\.25{corner-shape:superellipse(1.25)}.corner-t-superellipse\/1\.1{corner-top-left-shape:superellipse(1.1);corner-top-right-shape:superellipse(1.1)}.ring-inset{--tw-ring-inset:inset}.squircle{corner-shape:superellipse(1.1)}.squircle-outer{corner-shape:superellipse(1.25)}.stage-thread-flyout-preset-clamped{--stage-thread-flyout-preset-width:clamp(400px,25vw,500px)}.stage-thread-flyout-preset-default,.stage-thread-flyout-preset-responsive{--stage-thread-flyout-preset-width:400px}.stage-thread-flyout-preset-wide{--stage-thread-flyout-preset-width:600px}:is(.\*\:pointer-events-auto>*){pointer-events:auto}:is(.\*\:m-0>*){margin:calc(var(--spacing)*0)}:is(.\*\:mb-6>*){margin-bottom:calc(var(--spacing)*6)}:is(.\*\:flex>*){display:flex}:is(.\*\:inline>*){display:inline}:is(.\*\:inline-flex>*){display:inline-flex}:is(.\*\:h-full>*){height:100%}:is(.\*\:w-full>*){width:100%}:is(.\*\:items-center>*){align-items:center}:is(.\*\:justify-end>*){justify-content:flex-end}:is(.\*\:gap-2>*){gap:calc(var(--spacing)*2)}:is(.\*\:rounded-md>*){border-radius:var(--radius-md)}:is(.\*\:bg-gray-300>*){background-color:var(--gray-300)}:is(.\*\:object-center>*){object-position:center}:is(.\*\:p-4>*){padding:calc(var(--spacing)*4)}:is(.\*\:px-5>*){padding-inline:calc(var(--spacing)*5)}:is(.\*\:font-sans>*){font-family:"ui-sans-serif",-apple-system,"system-ui",Segoe UI,Helvetica,Apple Color Emoji,Arial,"sans-serif",Segoe UI Emoji,Segoe UI Symbol}:is(.\*\:font-normal>*){--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}:is(.\*\:shadow-lg>*){--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}:is(.\*\:\[text-box-edge\:text_alphabetic\]>*){text-box-edge:text alphabetic}:is(.\*\:\[text-box-trim\:trim-both\]>*){text-box-trim:trim-both}.not-group-data-disabled\:text-token-text-tertiary:not(:is(:where(.group)[data-disabled] *)){color:var(--text-tertiary)}.not-first\:mt-2:not(:first-child){margin-top:calc(var(--spacing)*2)}.not-first\:mt-4:not(:first-child){margin-top:calc(var(--spacing)*4)}.not-last\:mb-2:not(:last-child){margin-bottom:calc(var(--spacing)*2)}.not-last\:mb-5:not(:last-child){margin-bottom:calc(var(--spacing)*5)}.not-has-focus-visible\:sr-only:not(:has(:focus-visible)){clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-has-\[\>div\,\>section\,\>h1\,\>h2\,\>h3\,\>h4\,\>h5\,\>h6\]\:hidden:not(:has(>div,>section,>h1,>h2,>h3,>h4,>h5,>h6)){display:none}.not-dark\:shadow-\[inset_0_0_0_1px_rgba\(0\,0\,0\,0\.1\)\]:not(:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))){--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}@media not print{.not-print\:overflow-x-clip{overflow-x:clip}.not-print\:overflow-y-auto{overflow-y:auto}}@media not all and (min-height:700px){.not-tall\:relative{position:relative}}@media not all and (pointer:coarse){.not-touch\:mt-0\.5{margin-top:calc(var(--spacing)*.5)}}.not-keyboard-focused\:outline-none:not(:is(html[data-focus-mode=keyboard] :focus-visible)){--tw-outline-style:none;outline-style:none}.group-not-data-expanded\/composer\:-mb-12:is(:where(.group\/composer):not([data-expanded]) *){margin-bottom:calc(var(--spacing)*-12)}.group-last\:border-0:is(:where(.group):last-child *){border-style:var(--tw-border-style);border-width:0}.group-first-of-type\:rounded-t-2xl:is(:where(.group):first-of-type *){border-top-left-radius:var(--radius-2xl);border-top-right-radius:var(--radius-2xl)}.group-last-of-type\:rounded-b-2xl:is(:where(.group):last-of-type *){border-bottom-right-radius:var(--radius-2xl);border-bottom-left-radius:var(--radius-2xl)}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}.group-open\:text-token-text-primary:is(:where(.group):is([open],:popover-open,:open) *){color:var(--text-primary)}.group-focus-within\:pointer-events-auto:is(:where(.group):focus-within *){pointer-events:auto}.group-focus-within\:pointer-events-none:is(:where(.group):focus-within *){pointer-events:none}.group-focus-within\:translate-y-0:is(:where(.group):focus-within *){--tw-translate-y:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-focus-within\:scale-100:is(:where(.group):focus-within *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.group-focus-within\:grid-rows-\[1fr\]:is(:where(.group):focus-within *){grid-template-rows:1fr}.group-focus-within\:text-token-text-secondary:is(:where(.group):focus-within *){color:var(--text-secondary)}.group-focus-within\:opacity-0:is(:where(.group):focus-within *){opacity:0}.group-focus-within\:opacity-100:is(:where(.group):focus-within *){opacity:1}.group-focus-within\:delay-0:is(:where(.group):focus-within *){transition-delay:0s}.group-focus-within\:delay-100:is(:where(.group):focus-within *){transition-delay:.1s}.group-focus-within\/accordion\:opacity-40:is(:where(.group\/accordion):focus-within *){opacity:.4}.group-focus-within\/calendar-item\:pointer-events-auto:is(:where(.group\/calendar-item):focus-within *){pointer-events:auto}.group-focus-within\/calendar-item\:opacity-100:is(:where(.group\/calendar-item):focus-within *),.group-focus-within\/imagegen-image\:opacity-100:is(:where(.group\/imagegen-image):focus-within *){opacity:1}.group-focus-within\/item\:pointer-events-auto:is(:where(.group\/item):focus-within *){pointer-events:auto}.group-focus-within\/item\:opacity-100:is(:where(.group\/item):focus-within *){opacity:1}.group-focus-within\/memory-attribution\:pointer-events-auto:is(:where(.group\/memory-attribution):focus-within *){pointer-events:auto}.group-focus-within\/memory-attribution\:opacity-100:is(:where(.group\/memory-attribution):focus-within *){opacity:1}.group-focus-within\/numeric-stepper\:pointer-events-auto:is(:where(.group\/numeric-stepper):focus-within *){pointer-events:auto}.group-focus-within\/numeric-stepper\:opacity-100:is(:where(.group\/numeric-stepper):focus-within *){opacity:1}.group-focus-within\/search-image\:pointer-events-auto:is(:where(.group\/search-image):focus-within *){pointer-events:auto}.group-focus-within\/search-image\:visible:is(:where(.group\/search-image):focus-within *){visibility:visible}.group-focus-within\/search-image\:opacity-100:is(:where(.group\/search-image):focus-within *){opacity:1}.group-focus-within\/turn-messages\:pointer-events-auto:is(:where(.group\/turn-messages):focus-within *){pointer-events:auto}.group-focus-within\/turn-messages\:\[mask-position\:0_0\]:is(:where(.group\/turn-messages):focus-within *){-webkit-mask-position:0 0;mask-position:0 0}.group-focus-within\/turn-messages\:opacity-100:is(:where(.group\/turn-messages):focus-within *){opacity:1}@media (hover:hover){.group-hover\:pointer-events-auto:is(:where(.group):hover *){pointer-events:auto}.group-hover\:invisible:is(:where(.group):hover *){visibility:hidden}.group-hover\:visible:is(:where(.group):hover *){visibility:visible}.group-hover\:block:is(:where(.group):hover *){display:block}.group-hover\:flex:is(:where(.group):hover *){display:flex}.group-hover\:hidden:is(:where(.group):hover *){display:none}.group-hover\:-translate-x-1:is(:where(.group):hover *){--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:-translate-x-1\.5:is(:where(.group):hover *){--tw-translate-x:calc(var(--spacing)*-1.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:translate-x-0\.5:is(:where(.group):hover *){--tw-translate-x:calc(var(--spacing)*.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:translate-x-2\.5:is(:where(.group):hover *){--tw-translate-x:calc(var(--spacing)*2.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:translate-x-3\.5:is(:where(.group):hover *){--tw-translate-x:calc(var(--spacing)*3.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:translate-x-\[calc\(-50\%-245px\)\]:is(:where(.group):hover *){--tw-translate-x:calc(-50% - 245px);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:translate-x-\[calc\(-50\%-250px\)\]:is(:where(.group):hover *){--tw-translate-x:calc(-50% - 250px);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:translate-y-0:is(:where(.group):hover *){--tw-translate-y:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:translate-y-\[calc\(-50\%\+95px\)\]:is(:where(.group):hover *){--tw-translate-y:calc(-50% + 95px);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:translate-y-\[calc\(-50\%\+140px\)\]:is(:where(.group):hover *){--tw-translate-y:calc(-50% + 140px);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:translate-y-\[calc\(-50\%-100px\)\]:is(:where(.group):hover *){--tw-translate-y:calc(-50% - 100px);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:translate-y-\[calc\(-50\%-145px\)\]:is(:where(.group):hover *){--tw-translate-y:calc(-50% - 145px);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\:scale-100:is(:where(.group):hover *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.group-hover\:scale-105:is(:where(.group):hover *){--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x)var(--tw-scale-y)}.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x)var(--tw-scale-y)}.group-hover\:scale-200:is(:where(.group):hover *){--tw-scale-x:200%;--tw-scale-y:200%;--tw-scale-z:200%;scale:var(--tw-scale-x)var(--tw-scale-y)}.group-hover\:scale-\[1\.02\]:is(:where(.group):hover *){scale:1.02}.group-hover\:scale-\[1\.03\]:is(:where(.group):hover *){scale:1.03}.group-hover\:-rotate-\[8deg\]:is(:where(.group):hover *){rotate:-8deg}.group-hover\:-rotate-\[10deg\]:is(:where(.group):hover *){rotate:-10deg}.group-hover\:rotate-\[-2deg\]:is(:where(.group):hover *){rotate:-2deg}.group-hover\:rotate-\[-5deg\]:is(:where(.group):hover *){rotate:-5deg}.group-hover\:rotate-\[3deg\]:is(:where(.group):hover *){rotate:3deg}.group-hover\:rotate-\[60deg\]:is(:where(.group):hover *){rotate:60deg}.group-hover\:grid-rows-\[1fr\]:is(:where(.group):hover *){grid-template-rows:1fr}.group-hover\:border-black:is(:where(.group):hover *){border-color:#000}.group-hover\:border-orange-50:is(:where(.group):hover *){border-color:var(--orange-50)}.group-hover\:border-token-bg-tertiary:is(:where(.group):hover *){border-color:var(--bg-tertiary)}.group-hover\:border-token-text-primary:is(:where(.group):hover *){border-color:var(--text-primary)}.group-hover\:bg-black:is(:where(.group):hover *){background-color:#000}.group-hover\:bg-black\/5:is(:where(.group):hover *){background-color:#0000000d;background-color:lab(0% 0 0/.05)}.group-hover\:bg-gray-100:is(:where(.group):hover *){background-color:var(--gray-100)}.group-hover\:bg-token-bg-elevated-secondary:is(:where(.group):hover *){background-color:var(--bg-elevated-secondary)}.group-hover\:bg-token-bg-secondary:is(:where(.group):hover *){background-color:var(--bg-secondary)}.group-hover\:bg-token-bg-tertiary:is(:where(.group):hover *),.group-hover\:bg-token-bg-tertiary\/60:is(:where(.group):hover *){background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.group-hover\:bg-token-bg-tertiary\/60:is(:where(.group):hover *){background-color:color-mix(in oklab,var(--bg-tertiary)60%,transparent)}}.group-hover\:bg-token-interactive-bg-secondary-selected:is(:where(.group):hover *){background-color:var(--interactive-bg-secondary-selected)}.group-hover\:bg-token-main-surface-primary:is(:where(.group):hover *){background-color:var(--main-surface-primary)}.group-hover\:bg-token-main-surface-secondary:is(:where(.group):hover *){background-color:var(--main-surface-secondary)}.group-hover\:bg-token-text-tertiary:is(:where(.group):hover *){background-color:var(--text-tertiary)}.group-hover\:from-token-bg-tertiary:is(:where(.group):hover *){--tw-gradient-from:var(--bg-tertiary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.group-hover\:via-token-bg-tertiary:is(:where(.group):hover *){--tw-gradient-via:var(--bg-tertiary);--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.group-hover\:to-transparent:is(:where(.group):hover *){--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.group-hover\:\[mask-image\:linear-gradient\(to_right\,black_calc\(100\%_-_2\.5rem\)\,transparent_calc\(100\%_-_1\.25rem\)\,transparent_100\%\)\]:is(:where(.group):hover *){-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - 2.5rem),#0000 calc(100% - 1.25rem),#0000 100%);mask-image:linear-gradient(90deg,#000 calc(100% - 2.5rem),#0000 calc(100% - 1.25rem),#0000 100%)}.group-hover\:fill-\[\#8f5d1f\]:is(:where(.group):hover *){fill:#8f5d1f}.group-hover\:fill-token-text-inverted:is(:where(.group):hover *){fill:var(--text-inverted)}.group-hover\:entity-accent:is(:where(.group):hover *){color:var(--theme-entity-accent)}.group-hover\:text-red-500:is(:where(.group):hover *){color:var(--red-500)}.group-hover\:text-token-interactive-label-accent-default:is(:where(.group):hover *){color:var(--interactive-label-accent-default)}.group-hover\:text-token-link:is(:where(.group):hover *){color:var(--link)}.group-hover\:text-token-text-primary:is(:where(.group):hover *){color:var(--text-primary)}.group-hover\:text-token-text-secondary:is(:where(.group):hover *){color:var(--text-secondary)}.group-hover\:text-white:is(:where(.group):hover *){color:#fff}.group-hover\:text-white\/70:is(:where(.group):hover *){color:#ffffffb3;color:lab(100% -.0000298023 .0000119209/.7)}.group-hover\:underline:is(:where(.group):hover *){-webkit-text-decoration-line:underline;text-decoration-line:underline}.group-hover\:opacity-0:is(:where(.group):hover *){opacity:0}.group-hover\:opacity-20:is(:where(.group):hover *){opacity:.2}.group-hover\:opacity-70:is(:where(.group):hover *){opacity:.7}.group-hover\:opacity-90:is(:where(.group):hover *){opacity:.9}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-\[0px_4px_16px_0px_rgba\(0\,0\,0\,0\.05\)\]:is(:where(.group):hover *){--tw-shadow:0px 4px 16px 0px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.group-hover\:outline-2:is(:where(.group):hover *){outline-style:var(--tw-outline-style);outline-width:2px}.group-hover\:brightness-110:is(:where(.group):hover *){--tw-brightness:brightness(110%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.group-hover\:drop-shadow-\[0_2px_6px_rgba\(0\,0\,0\,0\.65\)\]:is(:where(.group):hover *){--tw-drop-shadow-size:drop-shadow(0 2px 6px var(--tw-drop-shadow-color,#000000a6));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.group-hover\:grayscale-0:is(:where(.group):hover *){--tw-grayscale:grayscale(0%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.group-hover\:backdrop-blur-3xl:is(:where(.group):hover *){--tw-backdrop-blur:blur(var(--blur-3xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.group-hover\:transition-transform:is(:where(.group):hover *){transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.group-hover\:delay-0:is(:where(.group):hover *){transition-delay:0s}.group-hover\:delay-200:is(:where(.group):hover *){transition-delay:.2s}.group-hover\:duration-0:is(:where(.group):hover *){--tw-duration:0s;transition-duration:0s}.group-hover\:duration-300:is(:where(.group):hover *){--tw-duration:.3s;transition-duration:.3s}.group-hover\:\[-webkit-mask-image\:linear-gradient\(to_right\,black_calc\(100\%_-_2\.5rem\)\,transparent_calc\(100\%_-_1\.25rem\)\,transparent_100\%\)\]:is(:where(.group):hover *){-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - 2.5rem),#0000 calc(100% - 1.25rem),#0000 100%)}.group-hover\/accordion\:opacity-40:is(:where(.group\/accordion):hover *){opacity:.4}.group-hover\/app-grid-item\:opacity-100:is(:where(.group\/app-grid-item):hover *),.group-hover\/audio-item\:opacity-100:is(:where(.group\/audio-item):hover *){opacity:1}.group-hover\/btn\:bg-gray-800:is(:where(.group\/btn):hover *){background-color:var(--gray-800)}.group-hover\/btn\:bg-token-bg-tertiary:is(:where(.group\/btn):hover *){background-color:var(--bg-tertiary)}.group-hover\/button\:bg-token-interactive-bg-tertiary-press:is(:where(.group\/button):hover *){background-color:var(--interactive-bg-tertiary-press)}.group-hover\/button\:text-token-text-primary:is(:where(.group\/button):hover *){color:var(--text-primary)}.group-hover\/calendar-item\:pointer-events-auto:is(:where(.group\/calendar-item):hover *){pointer-events:auto}.group-hover\/calendar-item\:opacity-100:is(:where(.group\/calendar-item):hover *){opacity:1}.group-hover\/card\:translate-y-0:is(:where(.group\/card):hover *){--tw-translate-y:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\/card\:scale-100:is(:where(.group\/card):hover *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.group-hover\/card\:opacity-100:is(:where(.group\/card):hover *){opacity:1}.group-hover\/carousel\:\[animation-play-state\:paused\]:is(:where(.group\/carousel):hover *){animation-play-state:paused}.group-hover\/cell\:opacity-0:is(:where(.group\/cell):hover *){opacity:0}.group-hover\/cell\:opacity-100:is(:where(.group\/cell):hover *){opacity:1}.group-hover\/dalle-image\:visible:is(:where(.group\/dalle-image):hover *){visibility:visible}.group-hover\/dalle-image\:bg-black\/70:is(:where(.group\/dalle-image):hover *){background-color:#000000b3;background-color:lab(0% 0 0/.7)}.group-hover\/dalle-image\:bg-transparent:is(:where(.group\/dalle-image):hover *){background-color:#0000}.group-hover\/debug\:opacity-100:is(:where(.group\/debug):hover *){opacity:1}.group-hover\/file-layout\:bg-token-interactive-bg-primary-selected\/5:is(:where(.group\/file-layout):hover *){background-color:var(--interactive-bg-primary-selected)}@supports (color:color-mix(in lab, red, red)){.group-hover\/file-layout\:bg-token-interactive-bg-primary-selected\/5:is(:where(.group\/file-layout):hover *){background-color:color-mix(in oklab,var(--interactive-bg-primary-selected)5%,transparent)}}.group-hover\/file-row\:opacity-100:is(:where(.group\/file-row):hover *){opacity:1}.group-hover\/file-tile\:block:is(:where(.group\/file-tile):hover *){display:block}.group-hover\/footnote\:border-token-bg-tertiary:is(:where(.group\/footnote):hover *){border-color:var(--bg-tertiary)}.group-hover\/footnote\:border-token-main-surface-secondary:is(:where(.group\/footnote):hover *){border-color:var(--main-surface-secondary)}.group-hover\/hover\:block:is(:where(.group\/hover):hover *){display:block}.group-hover\/icon\:bg-gray-200:is(:where(.group\/icon):hover *){background-color:var(--gray-200)}.group-hover\/imagegen-image\:opacity-100:is(:where(.group\/imagegen-image):hover *){opacity:1}.group-hover\/item\:pointer-events-auto:is(:where(.group\/item):hover *){pointer-events:auto}.group-hover\/item\:opacity-100:is(:where(.group\/item):hover *){opacity:1}.group-hover\/memory-attribution\:pointer-events-auto:is(:where(.group\/memory-attribution):hover *){pointer-events:auto}.group-hover\/memory-attribution\:opacity-100:is(:where(.group\/memory-attribution):hover *),.group-hover\/message\:opacity-100:is(:where(.group\/message):hover *){opacity:1}.group-hover\/nav-list\:underline:is(:where(.group\/nav-list):hover *){-webkit-text-decoration-line:underline;text-decoration-line:underline}.group-hover\/numeric-stepper\:pointer-events-auto:is(:where(.group\/numeric-stepper):hover *){pointer-events:auto}.group-hover\/numeric-stepper\:opacity-100:is(:where(.group\/numeric-stepper):hover *){opacity:1}.group-hover\/paragen-image\:visible:is(:where(.group\/paragen-image):hover *){visibility:visible}.group-hover\/pill\:translate-y-0:is(:where(.group\/pill):hover *){--tw-translate-y:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\/pill\:translate-y-full:is(:where(.group\/pill):hover *){--tw-translate-y:100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.group-hover\/pill\:opacity-0:is(:where(.group\/pill):hover *){opacity:0}.group-hover\/pill\:opacity-100:is(:where(.group\/pill):hover *){opacity:1}.group-hover\/pill\:blur-sm:is(:where(.group\/pill):hover *){--tw-blur:blur(var(--blur-sm));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.group-hover\/row\:bg-gray-50:is(:where(.group\/row):hover *){background-color:var(--gray-50)}.group-hover\/row\:underline:is(:where(.group\/row):hover *){-webkit-text-decoration-line:underline;text-decoration-line:underline}.group-hover\/row\:opacity-100:is(:where(.group\/row):hover *){opacity:1}.group-hover\/row\:delay-500:is(:where(.group\/row):hover *){transition-delay:.5s}.group-hover\/search-image\:pointer-events-auto:is(:where(.group\/search-image):hover *){pointer-events:auto}.group-hover\/search-image\:visible:is(:where(.group\/search-image):hover *){visibility:visible}.group-hover\/search-image\:opacity-100:is(:where(.group\/search-image):hover *),.group-hover\/segment\:opacity-100:is(:where(.group\/segment):hover *){opacity:1}.group-hover\/sidebar-expando-section\:visible:is(:where(.group\/sidebar-expando-section):hover *){visibility:visible}.group-hover\/sidebar-expando-section\:block:is(:where(.group\/sidebar-expando-section):hover *){display:block}.group-hover\/task-row\:bg-token-bg-secondary:is(:where(.group\/task-row):hover *){background-color:var(--bg-secondary)}.group-hover\/tiny-bar\:block:is(:where(.group\/tiny-bar):hover *){display:block}.group-hover\/tiny-bar\:hidden:is(:where(.group\/tiny-bar):hover *){display:none}.group-hover\/tool-message\:opacity-100:is(:where(.group\/tool-message):hover *){opacity:1}.group-hover\/tool-row\:visible:is(:where(.group\/tool-row):hover *){visibility:visible}.group-hover\/turn-messages\:pointer-events-auto:is(:where(.group\/turn-messages):hover *){pointer-events:auto}.group-hover\/turn-messages\:\[mask-position\:0_0\]:is(:where(.group\/turn-messages):hover *){-webkit-mask-position:0 0;mask-position:0 0}.group-hover\/turn-messages\:opacity-100:is(:where(.group\/turn-messages):hover *){opacity:1}.group-hover\/turn-messages\:delay-300:is(:where(.group\/turn-messages):hover *){transition-delay:.3s}}.group-focus\:text-token-text-primary:is(:where(.group):focus *){color:var(--text-primary)}.group-focus\:opacity-100:is(:where(.group):focus *),.group-focus\/imagegen-image\:opacity-100:is(:where(.group\/imagegen-image):focus *){opacity:1}.group-focus-visible\:block:is(:where(.group):focus-visible *){display:block}.group-focus-visible\:hidden:is(:where(.group):focus-visible *){display:none}.group-focus-visible\:scale-105:is(:where(.group):focus-visible *){--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x)var(--tw-scale-y)}.group-focus-visible\:opacity-100:is(:where(.group):focus-visible *){opacity:1}.group-focus-visible\:drop-shadow-\[0_2px_6px_rgba\(0\,0\,0\,0\.65\)\]:is(:where(.group):focus-visible *){--tw-drop-shadow-size:drop-shadow(0 2px 6px var(--tw-drop-shadow-color,#000000a6));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.group-focus-visible\/segment\:opacity-100:is(:where(.group\/segment):focus-visible *){opacity:1}.group-active\/file-layout\:bg-token-interactive-bg-primary-selected\/15:is(:where(.group\/file-layout):active *){background-color:var(--interactive-bg-primary-selected)}@supports (color:color-mix(in lab, red, red)){.group-active\/file-layout\:bg-token-interactive-bg-primary-selected\/15:is(:where(.group\/file-layout):active *){background-color:color-mix(in oklab,var(--interactive-bg-primary-selected)15%,transparent)}}.group-disabled\:opacity-50:is(:where(.group):disabled *){opacity:.5}.group-has-focus\:border-token-border-xheavy:is(:where(.group):has(:focus) *){border-color:var(--border-xheavy)}.group-data-disabled\:opacity-50:is(:where(.group)[data-disabled] *){opacity:.5}.group-data-expanded\/composer\:mb-0:is(:where(.group\/composer)[data-expanded] *){margin-bottom:calc(var(--spacing)*0)}.group-data-expanded\/composer\:px-2\.5:is(:where(.group\/composer)[data-expanded] *){padding-inline:calc(var(--spacing)*2.5)}.group-data-expanded\/composer\:\[grid-template-areas\:\'header_header_header\'_\'primary_primary_primary\'_\'leading_footer_trailing\'\]:is(:where(.group\/composer)[data-expanded] *){grid-template-areas:"header header header""primary primary primary""leading footer trailing"}.group-data-scrolled-from-end\/scrollport\:block:is(:where(.group\/scrollport)[data-scrolled-from-end] *){display:block}.group-data-scrolled-from-end\/scrollport\:opacity-85:is(:where(.group\/scrollport)[data-scrolled-from-end] *){opacity:.85}.group-data-scrolled-from-top\/scrollport\:bg-none\!:is(:where(.group\/scrollport)[data-scrolled-from-top] *){background-image:none!important}.group-data-scrolled-from-top\/scrollport\:opacity-100:is(:where(.group\/scrollport)[data-scrolled-from-top] *){opacity:1}.group-data-sheet-item\:mt-0\.5:is(:where(.group)[data-sheet-item] *){margin-top:calc(var(--spacing)*.5)}.group-data-sheet-item\:mb-0:is(:where(.group)[data-sheet-item] *){margin-bottom:calc(var(--spacing)*0)}.group-data-stream-active\/thread\:h-\(--thread-end-gutter-active-height\):is(:where(.group\/thread)[data-stream-active] *){height:var(--thread-end-gutter-active-height)}.group-data-stream-active\/thread\:\[overflow-anchor\:none\]:is(:where(.group\/thread)[data-stream-active] *){overflow-anchor:none}.group-data-\[disabled\]\/sharing-row\:text-token-text-tertiary:is(:where(.group\/sharing-row)[data-disabled] *){color:var(--text-tertiary)}.group-data-\[disabled\]\/sharing-row\:opacity-50:is(:where(.group\/sharing-row)[data-disabled] *){opacity:.5}.group-data-\[state\=off\]\:hidden:is(:where(.group)[data-state=off] *),.group-data-\[state\=on\]\:hidden:is(:where(.group)[data-state=on] *){display:none}.group-data-\[state\=open\]\:invisible:is(:where(.group)[data-state=open] *){visibility:hidden}.group-data-\[state\=open\]\:visible:is(:where(.group)[data-state=open] *){visibility:visible}.group-data-\[state\=open\]\:rotate-180:is(:where(.group)[data-state=open] *){rotate:180deg}.group-data-\[state\=open\]\/trigger\:block:is(:where(.group\/trigger)[data-state=open] *){display:block}.group-radix-disabled\:opacity-50:is(:where(.group)[data-disabled] *){opacity:.5}.group-radix-state-checked\:hidden:is(:where(.group)[data-state=checked] *){display:none}.group-radix-state-open\:bg-token-bg-tertiary:is(:where(.group)[data-state=open] *){background-color:var(--bg-tertiary)}.group-radix-state-open\:bg-token-main-surface-tertiary:is(:where(.group)[data-state=open] *){background-color:var(--main-surface-tertiary)}.group-keyboard-focused\:focus-ring:is(:where(.group):is(html[data-focus-mode=keyboard] :focus-visible) *){outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}.group-\[\.skeleton\]\:animate-\[shimmer-skeleton_2s_infinite_ease-in-out\]:is(:where(.group).skeleton *){animation:2s ease-in-out infinite shimmer-skeleton}.group-\[\.skeleton\]\:rounded-md:is(:where(.group).skeleton *){border-radius:var(--radius-md)}.group-\[\.skeleton\]\:bg-linear-to-r:is(:where(.group).skeleton *){--tw-gradient-position:to right}@supports (background-image:linear-gradient(in lab, red, red)){.group-\[\.skeleton\]\:bg-linear-to-r:is(:where(.group).skeleton *){--tw-gradient-position:to right in oklab}}.group-\[\.skeleton\]\:bg-linear-to-r:is(:where(.group).skeleton *){background-image:linear-gradient(var(--tw-gradient-stops))}.group-\[\.skeleton\]\:from-\[\#c1c0c0\]:is(:where(.group).skeleton *){--tw-gradient-from:#c1c0c0;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.group-\[\.skeleton\]\:via-\[\#f1f0f0\]:is(:where(.group).skeleton *){--tw-gradient-via:#f1f0f0;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.group-\[\.skeleton\]\:to-\[\#c1c0c0\:\]:is(:where(.group).skeleton *){--tw-gradient-to:#c1c0c0:;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.group-\[\.skeleton\]\:\[box-decoration-break\:clone\]:is(:where(.group).skeleton *){-webkit-box-decoration-break:clone;box-decoration-break:clone}.group-\[\.skeleton\]\:bg-\[length\:300\%\]:is(:where(.group).skeleton *){background-size:300%}.group-\[\.skeleton\]\:leading-7:is(:where(.group).skeleton *){--tw-leading:calc(var(--spacing)*7);line-height:calc(var(--spacing)*7)}.group-\[\.skeleton\]\:text-transparent:is(:where(.group).skeleton *){color:#0000}.group-\[\.skeleton\]\:\[animation-direction\:alternate\]:is(:where(.group).skeleton *){animation-direction:alternate}.group-\[\:not\(\:hover\)\]\:pointer-events-none:is(:where(.group):not(:hover) *){pointer-events:none}.group-\[\:not\(\:hover\)\]\:opacity-0:is(:where(.group):not(:hover) *){opacity:0}.group-\[\:not\(\:hover\)\:not\(\:focus-within\)\]\:pointer-events-none:is(:where(.group):not(:hover):not(:focus-within) *){pointer-events:none}.group-\[\:not\(\:hover\)\:not\(\:focus-within\)\]\:opacity-0:is(:where(.group):not(:hover):not(:focus-within) *){opacity:0}.group-\[\:not\(\[data-scroll-from-end\]\)\]\/thread\:pointer-events-none:is(:where(.group\/thread):not([data-scroll-from-end]) *){pointer-events:none}.group-\[\:not\(\[data-scroll-from-end\]\)\]\/thread\:scale-50:is(:where(.group\/thread):not([data-scroll-from-end]) *){--tw-scale-x:50%;--tw-scale-y:50%;--tw-scale-z:50%;scale:var(--tw-scale-x)var(--tw-scale-y)}.group-\[\:not\(\[data-scroll-from-end\]\)\]\/thread\:opacity-0:is(:where(.group\/thread):not([data-scroll-from-end]) *){opacity:0}.group-\[\:not\(\[data-scroll-from-end\]\)\]\/thread\:delay-0:is(:where(.group\/thread):not([data-scroll-from-end]) *){transition-delay:0s}.group-\[\:not\(\[data-scroll-from-end\]\)\]\/thread\:duration-100:is(:where(.group\/thread):not([data-scroll-from-end]) *){--tw-duration:.1s;transition-duration:.1s}.group-\[\:not\(\[data-scroll-from-top\]\)\]\/thread\:pointer-events-none:is(:where(.group\/thread):not([data-scroll-from-top]) *){pointer-events:none}.group-\[\:not\(\[data-scroll-from-top\]\)\]\/thread\:scale-50:is(:where(.group\/thread):not([data-scroll-from-top]) *){--tw-scale-x:50%;--tw-scale-y:50%;--tw-scale-z:50%;scale:var(--tw-scale-x)var(--tw-scale-y)}.group-\[\:not\(\[data-scroll-from-top\]\)\]\/thread\:opacity-0:is(:where(.group\/thread):not([data-scroll-from-top]) *){opacity:0}.group-\[\:not\(\[data-scroll-from-top\]\)\]\/thread\:delay-0:is(:where(.group\/thread):not([data-scroll-from-top]) *){transition-delay:0s}.group-\[\:not\(\[data-scroll-from-top\]\)\]\/thread\:duration-100:is(:where(.group\/thread):not([data-scroll-from-top]) *){--tw-duration:.1s;transition-duration:.1s}.peer-checked\:block:is(:where(.peer):checked~*){display:block}.peer-checked\:opacity-100:is(:where(.peer):checked~*){opacity:1}.peer-checked\/checkbox\:bg-token-interactive-bg-primary-default:is(:where(.peer\/checkbox):checked~*){background-color:var(--interactive-bg-primary-default)}.peer-checked\/checkbox\:text-token-icon-inverted:is(:where(.peer\/checkbox):checked~*){color:var(--icon-inverted)}.peer-indeterminate\:block:is(:where(.peer):indeterminate~*){display:block}.peer-indeterminate\:hidden:is(:where(.peer):indeterminate~*){display:none}.peer-indeterminate\:opacity-100:is(:where(.peer):indeterminate~*){opacity:1}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-70:is(:where(.peer):disabled~*){opacity:.7}.peer-disabled\/checkbox\:bg-token-bg-secondary:is(:where(.peer\/checkbox):disabled~*){background-color:var(--bg-secondary)}.first-letter\:uppercase:first-letter{text-transform:uppercase}.marker\:text-inherit ::marker{color:inherit}.marker\:text-inherit::marker{color:inherit}.marker\:text-inherit ::-webkit-details-marker{color:inherit}.marker\:text-inherit::-webkit-details-marker{color:inherit}.marker\:text-token-text-tertiary ::marker{color:var(--text-tertiary)}.marker\:text-token-text-tertiary::marker{color:var(--text-tertiary)}.marker\:text-token-text-tertiary ::-webkit-details-marker{color:var(--text-tertiary)}.marker\:text-token-text-tertiary::-webkit-details-marker{color:var(--text-tertiary)}.selection\:bg-token-bg-tertiary ::selection{background-color:var(--bg-tertiary)}.selection\:bg-token-bg-tertiary::selection{background-color:var(--bg-tertiary)}.selection\:bg-transparent ::selection{background-color:#0000}.selection\:bg-transparent::selection{background-color:#0000}.selection\:text-token-text-primary ::selection{color:var(--text-primary)}.selection\:text-token-text-primary::selection{color:var(--text-primary)}.file\:me-4:dir(ltr)::file-selector-button{margin-right:calc(var(--spacing)*4)}.file\:me-4:dir(rtl)::file-selector-button{margin-left:calc(var(--spacing)*4)}.file\:rounded-md::file-selector-button{border-radius:var(--radius-md)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-token-bg-secondary::file-selector-button{background-color:var(--bg-secondary)}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:px-3::file-selector-button{padding-inline:calc(var(--spacing)*3)}.file\:py-2::file-selector-button{padding-block:calc(var(--spacing)*2)}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-token-text-primary::file-selector-button{color:var(--text-primary)}.placeholder\:text-gray-300::placeholder{color:var(--gray-300)}.placeholder\:text-gray-400::placeholder{color:var(--gray-400)}.placeholder\:text-gray-500::placeholder{color:var(--gray-500)}.placeholder\:text-token-text-error::placeholder{color:var(--text-error)}.placeholder\:text-token-text-primary::placeholder,.placeholder\:text-token-text-primary\/40::placeholder{color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.placeholder\:text-token-text-primary\/40::placeholder{color:color-mix(in oklab,var(--text-primary)40%,transparent)}}.placeholder\:text-token-text-quaternary::placeholder{color:var(--text-quaternary)}.placeholder\:text-token-text-secondary::placeholder{color:var(--text-secondary)}.placeholder\:text-token-text-status-error::placeholder{color:var(--text-status-error)}.placeholder\:text-token-text-tertiary::placeholder,.placeholder\:text-token-text-tertiary\/70::placeholder{color:var(--text-tertiary)}@supports (color:color-mix(in lab, red, red)){.placeholder\:text-token-text-tertiary\/70::placeholder{color:color-mix(in oklab,var(--text-tertiary)70%,transparent)}}.placeholder\:select-none::placeholder{-webkit-user-select:none;user-select:none}.details-content\:grid::details-content{display:grid}.details-content\:grid-rows-\[0fr\]::details-content{grid-template-rows:0fr}.details-content\:overflow-clip::details-content{overflow:clip}.details-content\:\[transition-property\:content-visibility\,grid-template-rows\]::details-content{transition-property:content-visibility,grid-template-rows}.details-content\:transition-discrete::details-content{transition-behavior:allow-discrete}.details-content\:duration-300::details-content{--tw-duration:.3s;transition-duration:.3s}.details-content\:ease-spring-standard::details-content{--tw-ease:var(--spring-common);transition-timing-function:var(--spring-common)}.details-content\:\[overflow-clip-margin\:6px\]::details-content{overflow-clip-margin:6px}.before\:pointer-events-auto:before{content:var(--tw-content);pointer-events:auto}.before\:pointer-events-none:before{content:var(--tw-content);pointer-events:none}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:-inset-1:before{content:var(--tw-content);inset:calc(var(--spacing)*-1)}.before\:inset-0:before{content:var(--tw-content);inset:calc(var(--spacing)*0)}.before\:inset-\[-12px\]:before{content:var(--tw-content);top:-12px;bottom:-12px;left:-12px;right:-12px}.before\:inset-x-0:before{content:var(--tw-content);inset-inline:calc(var(--spacing)*0)}.before\:inset-x-8:before{content:var(--tw-content);inset-inline:calc(var(--spacing)*8)}.before\:inset-x-\[-1px\]:before{content:var(--tw-content);left:-1px;right:-1px}.before\:-inset-y-2:before{content:var(--tw-content);inset-block:calc(var(--spacing)*-2)}.before\:inset-y-0:before{content:var(--tw-content);inset-block:calc(var(--spacing)*0)}.before\:-start-0\.5:before{content:var(--tw-content)}.before\:-start-0\.5:dir(ltr):before{left:calc(var(--spacing)*-.5)}.before\:-start-0\.5:dir(rtl):before{right:calc(var(--spacing)*-.5)}.before\:top-\[-1px\]:before{content:var(--tw-content);top:-1px}.before\:top-\[-40px\]:before{content:var(--tw-content);top:-40px}.before\:top-\[-64px\]:before{content:var(--tw-content);top:-64px}.before\:top-full:before{content:var(--tw-content);top:100%}.before\:-right-2:before{content:var(--tw-content);right:calc(var(--spacing)*-2)}.before\:-right-2\.5:before{content:var(--tw-content);right:calc(var(--spacing)*-2.5)}.before\:bottom-0:before{content:var(--tw-content);bottom:calc(var(--spacing)*0)}.before\:bottom-2:before{content:var(--tw-content);bottom:calc(var(--spacing)*2)}.before\:-left-2\.5:before{content:var(--tw-content);left:calc(var(--spacing)*-2.5)}.before\:-left-4:before{content:var(--tw-content);left:calc(var(--spacing)*-4)}.before\:z-0:before{content:var(--tw-content);z-index:0}.before\:z-\[-1\]:before{content:var(--tw-content);z-index:-1}.before\:mx-4:before{content:var(--tw-content);margin-inline:calc(var(--spacing)*4)}.before\:my-1:before{content:var(--tw-content);margin-block:calc(var(--spacing)*1)}.before\:block:before{content:var(--tw-content);display:block}.before\:h-2:before{content:var(--tw-content);height:calc(var(--spacing)*2)}.before\:h-3:before{content:var(--tw-content);height:calc(var(--spacing)*3)}.before\:h-10:before{content:var(--tw-content);height:calc(var(--spacing)*10)}.before\:h-16:before{content:var(--tw-content);height:calc(var(--spacing)*16)}.before\:h-px:before{content:var(--tw-content);height:1px}.before\:w-full:before{content:var(--tw-content);width:100%}.before\:animate-\[pulse_2\.4s_ease-in-out_infinite\]:before{content:var(--tw-content);animation:2.4s ease-in-out infinite pulse}.before\:rounded-\[16px\]:before{content:var(--tw-content);border-radius:16px}.before\:rounded-full:before{content:var(--tw-content);border-radius:3.40282e38px}.before\:rounded-md:before{content:var(--tw-content);border-radius:var(--radius-md)}.before\:rounded-none:before{content:var(--tw-content);border-radius:0}.before\:bg-\[var\(--right-bg\)\]:before{content:var(--tw-content);background-color:var(--right-bg)}.before\:bg-black\/20:before{content:var(--tw-content);background-color:#0003;background-color:lab(0% 0 0/.2)}.before\:bg-black\/30:before{content:var(--tw-content);background-color:#0000004d;background-color:lab(0% 0 0/.3)}.before\:bg-gray-200\/50:before{content:var(--tw-content);background-color:var(--gray-200)}@supports (color:color-mix(in lab, red, red)){.before\:bg-gray-200\/50:before{background-color:color-mix(in oklab,var(--gray-200)50%,transparent)}}.before\:bg-token-bg-primary:before{content:var(--tw-content);background-color:var(--bg-primary)}.before\:bg-token-bg-tertiary\/60:before{content:var(--tw-content);background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.before\:bg-token-bg-tertiary\/60:before{background-color:color-mix(in oklab,var(--bg-tertiary)60%,transparent)}}.before\:bg-token-border-default:before{content:var(--tw-content);background-color:var(--border-default)}.before\:bg-token-surface-hover:before{content:var(--tw-content);background-color:var(--surface-hover)}.before\:bg-transparent:before{content:var(--tw-content);background-color:#0000}.before\:bg-white\/50:before{content:var(--tw-content);background-color:#ffffff80;background-color:lab(100% -.0000298023 .0000119209/.5)}.before\:bg-gradient-to-b:before{content:var(--tw-content);--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.before\:bg-gradient-to-l:before{content:var(--tw-content);--tw-gradient-position:to left in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.before\:bg-gradient-to-t:before{content:var(--tw-content);--tw-gradient-position:to top in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.before\:bg-\[linear-gradient\(90deg\,\#9FB1EE_0\%\,\#E6F3FF_100\%\)\]:before{content:var(--tw-content);background-image:linear-gradient(90deg,#9fb1ee 0%,#e6f3ff 100%)}.before\:from-token-bg-primary:before{content:var(--tw-content);--tw-gradient-from:var(--bg-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.before\:from-token-bg-tertiary:before{content:var(--tw-content);--tw-gradient-from:var(--bg-tertiary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.before\:from-transparent:before{content:var(--tw-content);--tw-gradient-from:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.before\:via-token-bg-primary\/45:before{content:var(--tw-content);--tw-gradient-via:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.before\:via-token-bg-primary\/45:before{--tw-gradient-via:color-mix(in oklab,var(--bg-primary)45%,transparent)}}.before\:via-token-bg-primary\/45:before{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.before\:via-token-bg-tertiary:before{content:var(--tw-content);--tw-gradient-via:var(--bg-tertiary);--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.before\:to-token-bg-primary:before{content:var(--tw-content);--tw-gradient-to:var(--bg-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.before\:to-transparent:before{content:var(--tw-content);--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.before\:opacity-0:before{content:var(--tw-content);opacity:0}.before\:opacity-60:before{content:var(--tw-content);opacity:.6}.before\:opacity-90:before{content:var(--tw-content);opacity:.9}.before\:opacity-100:before{content:var(--tw-content);opacity:1}.before\:shadow-\[0_0_18px_6px_rgba\(250\,226\,113\,0\.6\)\]:before{content:var(--tw-content);--tw-shadow:0 0 18px 6px var(--tw-shadow-color,#fae27199);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.before\:blur-md:before{content:var(--tw-content);--tw-blur:blur(var(--blur-md));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.before\:backdrop-blur-\[1px\]:before{content:var(--tw-content);--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.before\:transition:before{content:var(--tw-content);transition-property:color,background-color,border-color,outline-color,-webkit-text-decoration-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.before\:transition-opacity:before{content:var(--tw-content);transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.before\:duration-250:before{content:var(--tw-content);--tw-duration:.25s;transition-duration:.25s}.before\:content-\[\'\'\]:before{--tw-content:"";content:var(--tw-content)}.before\:content-\[\'\*\'\]:before{--tw-content:"*";content:var(--tw-content)}.not-data-hide-separator\:before\:mx-1:not([data-hide-separator]):before{content:var(--tw-content);margin-inline:calc(var(--spacing)*1)}.not-data-hide-separator\:before\:content-\[\'·\'\]:not([data-hide-separator]):before{--tw-content:"·";content:var(--tw-content)}@media not all and (prefers-reduced-motion:reduce){.not-motion-reduce\:before\:transition:before{content:var(--tw-content);transition-property:color,background-color,border-color,outline-color,-webkit-text-decoration-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.not-motion-reduce\:before\:duration-250:before{content:var(--tw-content);--tw-duration:.25s;transition-duration:.25s}}.after\:pointer-events-none:after{content:var(--tw-content);pointer-events:none}.after\:invisible:after{content:var(--tw-content);visibility:hidden}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:-inset-1:after{content:var(--tw-content);inset:calc(var(--spacing)*-1)}.after\:-inset-2:after{content:var(--tw-content);inset:calc(var(--spacing)*-2)}.after\:-inset-4:after{content:var(--tw-content);inset:calc(var(--spacing)*-4)}.after\:inset-0:after{content:var(--tw-content);inset:calc(var(--spacing)*0)}.after\:inset-\[-4px\]:after{content:var(--tw-content);top:-4px;bottom:-4px;left:-4px;right:-4px}.after\:-inset-x-2:after{content:var(--tw-content);inset-inline:calc(var(--spacing)*-2)}.after\:inset-x-2:after{content:var(--tw-content);inset-inline:calc(var(--spacing)*2)}.after\:inset-x-\[-4px\]:after{content:var(--tw-content);left:-4px;right:-4px}.after\:-inset-y-4:after{content:var(--tw-content);inset-block:calc(var(--spacing)*-4)}.after\:inset-y-0:after{content:var(--tw-content);inset-block:calc(var(--spacing)*0)}.after\:-start-1:after{content:var(--tw-content)}.after\:-start-1:dir(ltr):after{left:calc(var(--spacing)*-1)}.after\:-start-1:dir(rtl):after{right:calc(var(--spacing)*-1)}.after\:start-0:after{content:var(--tw-content)}.after\:start-0:dir(ltr):after{left:calc(var(--spacing)*0)}.after\:start-0:dir(rtl):after{right:calc(var(--spacing)*0)}.after\:start-1\/2:after{content:var(--tw-content)}.after\:start-1\/2:dir(ltr):after{left:50%}.after\:start-1\/2:dir(rtl):after{right:50%}.after\:start-4:after{content:var(--tw-content)}.after\:start-4:dir(ltr):after{left:calc(var(--spacing)*4)}.after\:start-4:dir(rtl):after{right:calc(var(--spacing)*4)}.after\:start-\[\+150px\]:after{content:var(--tw-content)}.after\:start-\[\+150px\]:dir(ltr):after{left:150px}.after\:start-\[\+150px\]:dir(rtl):after{right:150px}.after\:start-\[-15px\]:after{content:var(--tw-content)}.after\:start-\[-15px\]:dir(ltr):after{left:-15px}.after\:start-\[-15px\]:dir(rtl):after{right:-15px}.after\:start-\[calc\(100\%_\+_280px\)\]:after{content:var(--tw-content)}.after\:start-\[calc\(100\%_\+_280px\)\]:dir(ltr):after{left:calc(100% + 280px)}.after\:start-\[calc\(100\%_\+_280px\)\]:dir(rtl):after{right:calc(100% + 280px)}.after\:start-\[calc\(100\%_-_25px\)\]:after{content:var(--tw-content)}.after\:start-\[calc\(100\%_-_25px\)\]:dir(ltr):after{left:calc(100% - 25px)}.after\:start-\[calc\(100\%_-_25px\)\]:dir(rtl):after{right:calc(100% - 25px)}.after\:-end-4:after{content:var(--tw-content)}.after\:-end-4:dir(ltr):after{right:calc(var(--spacing)*-4)}.after\:-end-4:dir(rtl):after{left:calc(var(--spacing)*-4)}.after\:end-0:after{content:var(--tw-content)}.after\:end-0:dir(ltr):after{right:calc(var(--spacing)*0)}.after\:end-0:dir(rtl):after{left:calc(var(--spacing)*0)}.after\:end-4:after{content:var(--tw-content)}.after\:end-4:dir(ltr):after{right:calc(var(--spacing)*4)}.after\:end-4:dir(rtl):after{left:calc(var(--spacing)*4)}.after\:end-\[-15\%\]:after{content:var(--tw-content)}.after\:end-\[-15\%\]:dir(ltr):after{right:-15%}.after\:end-\[-15\%\]:dir(rtl):after{left:-15%}.after\:top-0:after{content:var(--tw-content);top:calc(var(--spacing)*0)}.after\:top-\[-18px\]:after{content:var(--tw-content);top:-18px}.after\:top-\[-30px\]:after{content:var(--tw-content);top:-30px}.after\:top-\[-45px\]:after{content:var(--tw-content);top:-45px}.after\:top-\[-95px\]:after{content:var(--tw-content);top:-95px}.after\:top-\[-100\%\]:after{content:var(--tw-content);top:-100%}.after\:bottom-0:after{content:var(--tw-content);bottom:calc(var(--spacing)*0)}.after\:bottom-\[75\%\]:after{content:var(--tw-content);bottom:75%}.after\:z-0:after{content:var(--tw-content);z-index:0}.after\:z-3:after{content:var(--tw-content);z-index:3}.after\:z-\[-1\]:after{content:var(--tw-content);z-index:-1}.after\:mx-1:after{content:var(--tw-content);margin-inline:calc(var(--spacing)*1)}.after\:block:after{content:var(--tw-content);display:block}.after\:hidden:after{content:var(--tw-content);display:none}.after\:h-0\.5:after{content:var(--tw-content);height:calc(var(--spacing)*.5)}.after\:h-2:after{content:var(--tw-content);height:calc(var(--spacing)*2)}.after\:h-8:after{content:var(--tw-content);height:calc(var(--spacing)*8)}.after\:h-\[1px\]:after{content:var(--tw-content);height:1px}.after\:h-\[28px\]:after{content:var(--tw-content);height:28px}.after\:h-\[64px\]:after{content:var(--tw-content);height:64px}.after\:h-\[120\%\]:after{content:var(--tw-content);height:120%}.after\:h-\[140px\]:after{content:var(--tw-content);height:140px}.after\:h-\[144px\]:after{content:var(--tw-content);height:144px}.after\:h-\[200px\]:after{content:var(--tw-content);height:200px}.after\:h-\[calc\(100\%\+36px\)\]:after{content:var(--tw-content);height:calc(100% + 36px)}.after\:h-full:after{content:var(--tw-content);height:100%}.after\:h-px:after{content:var(--tw-content);height:1px}.after\:w-1:after{content:var(--tw-content);width:calc(var(--spacing)*1)}.after\:w-2:after{content:var(--tw-content);width:calc(var(--spacing)*2)}.after\:w-4:after{content:var(--tw-content);width:calc(var(--spacing)*4)}.after\:w-\[16px\]:after{content:var(--tw-content);width:16px}.after\:w-\[75px\]:after{content:var(--tw-content);width:75px}.after\:w-\[80\%\]:after{content:var(--tw-content);width:80%}.after\:w-\[113px\]:after{content:var(--tw-content);width:113px}.after\:w-\[120\%\]:after{content:var(--tw-content);width:120%}.after\:w-\[255px\]:after{content:var(--tw-content);width:255px}.after\:w-px:after{content:var(--tw-content);width:1px}.after\:max-w-\[340px\]:after{content:var(--tw-content);max-width:340px}.after\:min-w-\[2ch\]:after{content:var(--tw-content);min-width:2ch}.after\:shrink-0:after{content:var(--tw-content);flex-shrink:0}.after\:-translate-x-1\/2:after{content:var(--tw-content);--tw-translate-x:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.after\:transform:after{content:var(--tw-content);transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.after\:rounded-\[50\%\]:after{content:var(--tw-content);border-radius:50%}.after\:rounded-\[inherit\]:after{content:var(--tw-content);border-radius:inherit}.after\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\:rounded-lg:after{content:var(--tw-content);border-radius:var(--radius-lg)}.after\:rounded-md:after{content:var(--tw-content);border-radius:var(--radius-md)}.after\:rounded-b-2xl:after{content:var(--tw-content);border-bottom-right-radius:var(--radius-2xl);border-bottom-left-radius:var(--radius-2xl)}.after\:border-s:after{content:var(--tw-content)}.after\:border-s:dir(ltr):after{border-left-style:var(--tw-border-style);border-left-width:1px}.after\:border-s:dir(rtl):after{border-right-style:var(--tw-border-style);border-right-width:1px}.after\:border-e:after{content:var(--tw-content)}.after\:border-e:dir(ltr):after{border-right-style:var(--tw-border-style);border-right-width:1px}.after\:border-e:dir(rtl):after{border-left-style:var(--tw-border-style);border-left-width:1px}.after\:border-b:after{content:var(--tw-content);border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.after\:border-token-border-default:after{content:var(--tw-content);border-color:var(--border-default)}.after\:border-token-interactive-border-tertiary-default:after{content:var(--tw-content);border-color:var(--interactive-border-tertiary-default)}.after\:bg-\[\#0d0d0d\]:after{content:var(--tw-content);background-color:#0d0d0d}.after\:bg-\[Highlight\]:after{content:var(--tw-content);background-color:highlight}.after\:bg-green-500:after{content:var(--tw-content);background-color:var(--green-500)}.after\:bg-red-500:after{content:var(--tw-content);background-color:var(--red-500)}.after\:bg-token-border-default:after{content:var(--tw-content);background-color:var(--border-default)}.after\:bg-token-border-heavy:after{content:var(--tw-content);background-color:var(--border-heavy)}.after\:bg-token-border-light:after{content:var(--tw-content);background-color:var(--border-light)}.after\:bg-token-main-surface-primary:after{content:var(--tw-content);background-color:var(--main-surface-primary)}.after\:bg-token-text-primary:after{content:var(--tw-content);background-color:var(--text-primary)}.after\:bg-transparent:after{content:var(--tw-content);background-color:#0000}.after\:bg-gradient-to-t:after{content:var(--tw-content);--tw-gradient-position:to top in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.after\:bg-\[linear-gradient\(180deg\,rgba\(255\,255\,255\,0\)_24\.327\%\,\#ffffff_47\.029\%\)\]:after{content:var(--tw-content);background-image:linear-gradient(#fff0 24.327%,#fff 47.029%)}.after\:bg-\[url\(https\:\/\/cdn\.openai\.com\/chatgpt\/ctf-cdn\/student-landing\/cards-more-v2\.png\)\]:after{content:var(--tw-content);background-image:url(https://cdn.openai.com/chatgpt/ctf-cdn/student-landing/cards-more-v2.png)}.after\:bg-\[url\(https\:\/\/cdn\.openai\.com\/chatgpt\/ctf-cdn\/student-landing\/faq-bubble-small-v2\.png\)\]:after{content:var(--tw-content);background-image:url(https://cdn.openai.com/chatgpt/ctf-cdn/student-landing/faq-bubble-small-v2.png)}.after\:bg-\[url\(https\:\/\/cdn\.openai\.com\/chatgpt\/ctf-cdn\/student-landing\/offer-flowers-v2\.png\)\]:after{content:var(--tw-content);background-image:url(https://cdn.openai.com/chatgpt/ctf-cdn/student-landing/offer-flowers-v2.png)}.after\:bg-\[url\(https\:\/\/cdn\.openai\.com\/chatgpt\/ctf-cdn\/student-landing\/splash-scribble-v2\.png\)\]:after{content:var(--tw-content);background-image:url(https://cdn.openai.com/chatgpt/ctf-cdn/student-landing/splash-scribble-v2.png)}.after\:bg-\[url\(https\:\/\/cdn\.openai\.com\/chatgpt\/ctf-cdn\/student-landing\/splash-stars-v2\.png\)\]:after{content:var(--tw-content);background-image:url(https://cdn.openai.com/chatgpt/ctf-cdn/student-landing/splash-stars-v2.png)}.after\:from-white:after{content:var(--tw-content);--tw-gradient-from:#fff;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.after\:via-white:after{content:var(--tw-content);--tw-gradient-via:#fff;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.after\:to-transparent:after{content:var(--tw-content);--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.after\:bg-contain:after{content:var(--tw-content);background-size:contain}.after\:bg-bottom:after{content:var(--tw-content);background-position:bottom}.after\:bg-center:after{content:var(--tw-content);background-position:50%}.after\:bg-no-repeat:after{content:var(--tw-content);background-repeat:no-repeat}.after\:font-mono:after{content:var(--tw-content);font-family:"ui-monospace",SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,"monospace"}.after\:whitespace-pre:after{content:var(--tw-content);white-space:pre}.after\:opacity-0:after{content:var(--tw-content);opacity:0}.after\:opacity-80:after{content:var(--tw-content);opacity:.8}.after\:opacity-100:after{content:var(--tw-content);opacity:1}.after\:\[outline\:2px_auto_-webkit-focus-ring-color\]:after{content:var(--tw-content);outline:2px auto -webkit-focus-ring-color}.after\:\[outline-offset\:-2px\]:after{content:var(--tw-content);outline-offset:-2px}.after\:transition-opacity:after{content:var(--tw-content);transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.after\:content-\[\'\\u00b7\'\]:after{--tw-content:"u00b7";content:var(--tw-content)}.after\:content-\[attr\(data-value\)\]:after{--tw-content:attr(data-value);content:var(--tw-content)}.after\:\[grid-area\:1\/1\]:after{content:var(--tw-content);grid-area:1/1}:is(.\*\:not-last\:after\:px-0\.5>*):not(:last-child):after{content:var(--tw-content);padding-inline:calc(var(--spacing)*.5)}:is(.\*\:not-last\:after\:content-\[\'\+\'\]>*):not(:last-child):after{--tw-content:"+";content:var(--tw-content)}.group-last\:after\:hidden:is(:where(.group):last-child *):after{content:var(--tw-content);display:none}.first\:-ms-1:first-child:dir(ltr){margin-left:calc(var(--spacing)*-1)}.first\:-ms-1:first-child:dir(rtl){margin-right:calc(var(--spacing)*-1)}.first\:ms-0:first-child:dir(ltr){margin-left:calc(var(--spacing)*0)}.first\:ms-0:first-child:dir(rtl){margin-right:calc(var(--spacing)*0)}.first\:ms-4:first-child:dir(ltr){margin-left:calc(var(--spacing)*4)}.first\:ms-4:first-child:dir(rtl){margin-right:calc(var(--spacing)*4)}.first\:me-0:first-child:dir(ltr){margin-right:calc(var(--spacing)*0)}.first\:me-0:first-child:dir(rtl){margin-left:calc(var(--spacing)*0)}.first\:mt-0:first-child{margin-top:calc(var(--spacing)*0)}.first\:hidden:first-child{display:none}.first\:rounded-t:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.first\:border-0:first-child{border-style:var(--tw-border-style);border-width:0}.first\:border-t-0:first-child{border-top-style:var(--tw-border-style);border-top-width:0}.first\:border-none:first-child{--tw-border-style:none;border-style:none}.first\:ps-0\!:first-child:dir(ltr){padding-left:calc(var(--spacing)*0)}.first\:ps-0\!:first-child:dir(rtl){padding-right:calc(var(--spacing)*0)}.first\:pt-0:first-child{padding-top:calc(var(--spacing)*0)}.first\:pt-4:first-child{padding-top:calc(var(--spacing)*4)}.first\:pt-\[3px\]:first-child{padding-top:3px}:is(.\*\:first\:m-0\!>*):first-child{margin:calc(var(--spacing)*0)!important}:is(.\*\:first\:h-full>*):first-child{height:100%}.first\:before\:hidden:first-child:before{content:var(--tw-content);display:none}.last\:me-0:last-child:dir(ltr){margin-right:calc(var(--spacing)*0)}.last\:me-0:last-child:dir(rtl){margin-left:calc(var(--spacing)*0)}.last\:me-4:last-child:dir(ltr){margin-right:calc(var(--spacing)*4)}.last\:me-4:last-child:dir(rtl){margin-left:calc(var(--spacing)*4)}.last\:-mb-4:last-child{margin-bottom:calc(var(--spacing)*-4)}.last\:mb-0:last-child{margin-bottom:calc(var(--spacing)*0)}.last\:mb-2:last-child{margin-bottom:calc(var(--spacing)*2)}.last\:mb-5:last-child{margin-bottom:calc(var(--spacing)*5)}.last\:hidden:last-child{display:none}.last\:min-h-\[calc\(100vh-8rem\)\]:last-child{min-height:calc(100vh - 8rem)}.last\:snap-end:last-child{scroll-snap-align:end}.last\:scroll-mb-20:last-child{scroll-margin-bottom:calc(var(--spacing)*20)}.last\:scroll-pb-20:last-child{scroll-padding-bottom:calc(var(--spacing)*20)}.last\:rounded-b:last-child{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-e-0:last-child:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:0}.last\:border-e-0:last-child:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:0}.last\:border-b:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last\:border-none:last-child{--tw-border-style:none;border-style:none}.last\:pe-0:last-child:dir(ltr){padding-right:calc(var(--spacing)*0)}.last\:pe-0:last-child:dir(rtl){padding-left:calc(var(--spacing)*0)}.last\:pe-0\!:last-child:dir(ltr){padding-right:calc(var(--spacing)*0)}.last\:pe-0\!:last-child:dir(rtl){padding-left:calc(var(--spacing)*0)}.last\:pb-0:last-child{padding-bottom:calc(var(--spacing)*0)}.last\:pb-4:last-child{padding-bottom:calc(var(--spacing)*4)}.last\:pb-20:last-child{padding-bottom:calc(var(--spacing)*20)}.last\:after\:content-\[none\]:last-child:after{--tw-content:none;content:var(--tw-content)}.first-of-type\:rounded-t-2xl:first-of-type{border-top-left-radius:var(--radius-2xl);border-top-right-radius:var(--radius-2xl)}.first-of-type\:border-none:first-of-type{--tw-border-style:none;border-style:none}.last-of-type\:rounded-b-2xl:last-of-type{border-bottom-right-radius:var(--radius-2xl);border-bottom-left-radius:var(--radius-2xl)}.last-of-type\:border-0:last-of-type{border-style:var(--tw-border-style);border-width:0}.last-of-type\:border-b:last-of-type{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.last-of-type\:border-b-0:last-of-type{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last-of-type\:border-none:last-of-type{--tw-border-style:none;border-style:none}.open\:flex:is([open],:popover-open,:open){display:flex}.checked\:border-black:checked{border-color:#000}.checked\:border-black\!:checked{border-color:#000!important}.checked\:border-blue-400\!:checked{border-color:var(--blue-400)!important}.checked\:border-blue-500:checked{border-color:var(--blue-500)}.checked\:border-token-text-primary:checked{border-color:var(--text-primary)}.checked\:bg-black:checked{background-color:#000}.checked\:bg-black\!:checked{background-color:#000!important}.checked\:bg-blue-400\!:checked{background-color:var(--blue-400)!important}.checked\:bg-blue-500:checked{background-color:var(--blue-500)}.checked\:bg-token-text-primary:checked{background-color:var(--text-primary)}.indeterminate\:border-black:indeterminate{border-color:#000}.indeterminate\:border-blue-500:indeterminate{border-color:var(--blue-500)}.indeterminate\:bg-black:indeterminate{background-color:#000}.indeterminate\:bg-blue-500:indeterminate{background-color:var(--blue-500)}.empty\:hidden:empty{display:none}.empty\:border-0:empty{border-style:var(--tw-border-style);border-width:0}.focus-within\:relative:focus-within{position:relative}.focus-within\:z-10:focus-within{z-index:10}.focus-within\:rounded-lg:focus-within{border-radius:var(--radius-lg)}.focus-within\:border-0\!:focus-within{border-style:var(--tw-border-style)!important;border-width:0!important}.focus-within\:border-s-2:focus-within:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:2px}.focus-within\:border-s-2:focus-within:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:2px}.focus-within\:border-green-500:focus-within{border-color:var(--green-500)}.focus-within\:border-red-500:focus-within{border-color:var(--red-500)}.focus-within\:border-token-border-default\/80:focus-within{border-color:var(--border-default)}@supports (color:color-mix(in lab, red, red)){.focus-within\:border-token-border-default\/80:focus-within{border-color:color-mix(in oklab,var(--border-default)80%,transparent)}}.focus-within\:border-token-border-heavy:focus-within{border-color:var(--border-heavy)}.focus-within\:border-token-border-heavy\!:focus-within{border-color:var(--border-heavy)!important}.focus-within\:border-token-border-status-error:focus-within{border-color:var(--border-status-error)}.focus-within\:border-token-border-xheavy:focus-within{border-color:var(--border-xheavy)}.focus-within\:border-token-text-primary:focus-within{border-color:var(--text-primary)}.focus-within\:bg-black\/7:focus-within{background-color:#00000012;background-color:lab(0% 0 0/.07)}.focus-within\:bg-token-bg-secondary:focus-within{background-color:var(--bg-secondary)}.focus-within\:bg-token-bg-tertiary:focus-within{background-color:var(--bg-tertiary)}.focus-within\:bg-token-main-surface-tertiary:focus-within{background-color:var(--main-surface-tertiary)}.focus-within\:ps-\[-2px\]:focus-within:dir(ltr){padding-left:-2px}.focus-within\:ps-\[-2px\]:focus-within:dir(rtl){padding-right:-2px}.focus-within\:opacity-100:focus-within{opacity:1}.focus-within\:opacity-100\!:focus-within{opacity:1!important}.focus-within\:shadow-\[0_0_0_2px\]:focus-within{--tw-shadow:0 0 0 2px var(--tw-shadow-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-within\:shadow-\[0px_0px_1px_0px_\#0000004D\,_0px_4px_4px_0px_\#0000000A\]:focus-within{--tw-shadow:0px 0px 1px 0px var(--tw-shadow-color,#0000004d),0px 4px 4px 0px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-within\:shadow-none:focus-within{--tw-shadow:0 0 transparent;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-within\:ring-0:focus-within{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-within\:ring-0\!:focus-within{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor)!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.focus-within\:ring-1:focus-within{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-within\:ring-blue-400\/60:focus-within{--tw-ring-color:var(--blue-400)}@supports (color:color-mix(in lab, red, red)){.focus-within\:ring-blue-400\/60:focus-within{--tw-ring-color:color-mix(in oklab,var(--blue-400)60%,transparent)}}.focus-within\:ring-red-500:focus-within{--tw-ring-color:var(--red-500)}.focus-within\:ring-token-border-default\/40:focus-within{--tw-ring-color:var(--border-default)}@supports (color:color-mix(in lab, red, red)){.focus-within\:ring-token-border-default\/40:focus-within{--tw-ring-color:color-mix(in oklab,var(--border-default)40%,transparent)}}.focus-within\:ring-token-text-primary:focus-within{--tw-ring-color:var(--text-primary)}.focus-within\:ring-token-text-secondary:focus-within{--tw-ring-color:var(--text-secondary)}.focus-within\:ring-token-text-status-error:focus-within{--tw-ring-color:var(--text-status-error)}.focus-within\:ring-transparent:focus-within{--tw-ring-color:transparent}.focus-within\:outline-hidden:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus-within\:outline-hidden:focus-within{outline-offset:2px;outline:2px solid #0000}}.focus-within\:transition-none:focus-within{transition-property:none}.focus-within\:outline-none:focus-within{--tw-outline-style:none;outline-style:none}@media (hover:hover){.hover\:visible:hover{visibility:visible}.hover\:entity-underline\!:hover{vertical-align:baseline!important;-webkit-text-decoration-line:underline!important;text-decoration-line:underline!important;-webkit-text-decoration-color:var(--text-tertiary)!important;-webkit-text-decoration-color:var(--text-tertiary)!important;-webkit-text-decoration-color:var(--text-tertiary)!important;-webkit-text-decoration-color:var(--text-tertiary)!important;text-decoration-color:var(--text-tertiary)!important;text-underline-offset:2px!important;-webkit-text-decoration-style:dotted!important;text-decoration-style:dotted!important;text-decoration-thickness:1px!important;display:inline!important}@media (hover:hover){.hover\:entity-underline\!:hover:hover{-webkit-text-decoration-color:inherit!important;-webkit-text-decoration-color:inherit!important;-webkit-text-decoration-color:inherit!important;-webkit-text-decoration-color:inherit!important;text-decoration-color:inherit!important}}.hover\:entity-underline\!:hover{-webkit-text-decoration-skip-ink:auto!important;text-decoration-skip-ink:auto!important;text-underline-position:from-font!important}.hover\:-translate-y-0\.5:hover{--tw-translate-y:calc(var(--spacing)*-.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.hover\:scale-103:hover{--tw-scale-x:103%;--tw-scale-y:103%;--tw-scale-z:103%;scale:var(--tw-scale-x)var(--tw-scale-y)}.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x)var(--tw-scale-y)}.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x)var(--tw-scale-y)}.hover\:scale-125:hover{--tw-scale-x:125%;--tw-scale-y:125%;--tw-scale-z:125%;scale:var(--tw-scale-x)var(--tw-scale-y)}.hover\:scale-\[1\.01\]:hover{scale:1.01}.hover\:scale-\[1\.005\]:hover{scale:1.005}.hover\:scale-\[1\.015\]:hover{scale:1.015}.hover\:rotate-\[20deg\]:hover{rotate:20deg}.hover\:cursor-default:hover{cursor:default}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:rounded-2xl:hover{border-radius:var(--radius-2xl)}.hover\:rounded-lg:hover{border-radius:var(--radius-lg)}.hover\:border-\[var\(--interactive-border-secondary-hover\)\]:hover{border-color:var(--interactive-border-secondary-hover)}.hover\:border-gray-300:hover{border-color:var(--gray-300)}.hover\:border-token-border-default\/80:hover{border-color:var(--border-default)}@supports (color:color-mix(in lab, red, red)){.hover\:border-token-border-default\/80:hover{border-color:color-mix(in oklab,var(--border-default)80%,transparent)}}.hover\:border-token-border-heavy:hover{border-color:var(--border-heavy)}.hover\:border-token-border-light:hover{border-color:var(--border-light)}.hover\:border-token-border-medium:hover{border-color:var(--border-medium)}.hover\:border-token-border-xheavy:hover{border-color:var(--border-xheavy)}.hover\:border-token-icon-accent:hover{border-color:var(--icon-accent)}.hover\:border-token-text-primary\/44:hover{border-color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.hover\:border-token-text-primary\/44:hover{border-color:color-mix(in oklab,var(--text-primary)44%,transparent)}}.hover\:border-white\/30:hover{border-color:#ffffff4d;border-color:lab(100% -.0000298023 .0000119209/.3)}.hover\:\!bg-\[\#2A2A2A\]:hover{background-color:#2a2a2a!important}.hover\:\!bg-\[\#F8F8F8\]:hover{background-color:#f8f8f8!important}.hover\:bg-\[\#0FA968\]\/15:hover{background-color:#0fa96826;background-color:lab(61.1228% -49.5053 22.9273/.15)}.hover\:bg-\[\#0000000a\]:hover{background-color:#0000000a}.hover\:bg-\[\#6a6a6a\]:hover{background-color:#6a6a6a}.hover\:bg-\[\#0276E0\]:hover{background-color:#0276e0}.hover\:bg-\[\#0285FF\]\/15:hover{background-color:#0285ff26;background-color:lab(54.959% 5.86918 -70.2582/.15)}.hover\:bg-\[\#BDDCF4\]:hover{background-color:#bddcf4}.hover\:bg-\[\#E4E4F6\]:hover{background-color:#e4e4f6}.hover\:bg-\[\#ECECFF\]:hover{background-color:#ececff}.hover\:bg-\[\#f5f5f5\]:hover{background-color:#f5f5f5}.hover\:bg-\[\#f6dc63\]:hover{background-color:#f6dc63}.hover\:bg-\[color-mix\(in_srgb\,var\(--main-surface-secondary\)_72\%\,transparent\)\]:hover{background-color:var(--main-surface-secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-\[color-mix\(in_srgb\,var\(--main-surface-secondary\)_72\%\,transparent\)\]:hover{background-color:color-mix(in srgb,var(--main-surface-secondary)72%,transparent)}}.hover\:bg-\[rgba\(0\,0\,0\,0\.1\)\]:hover{background-color:#0000001a}.hover\:bg-\[rgba\(0\,0\,0\,0\.08\)\]:hover{background-color:#00000014}.hover\:bg-\[rgba\(29\,155\,209\,0\.2\)\]:hover{background-color:#1d9bd133}.hover\:bg-\[rgba\(217\,238\,255\,0\.85\)\]\!:hover{background-color:#d9eeffd9!important}.hover\:bg-\[var\(--bg-tertiary\,\#EBEBEB\)\]:hover{background-color:var(--bg-tertiary,#ebebeb)}.hover\:bg-\[var\(--bg-tertiary\,\#F3F3F3\)\]:hover{background-color:var(--bg-tertiary,#f3f3f3)}.hover\:bg-\[var\(--prompt-icon-hover-bg\)\]:hover{background-color:var(--prompt-icon-hover-bg)}.hover\:bg-\[var\(--scrollbar-color-hover\)\]:hover{background-color:var(--scrollbar-color-hover)}.hover\:bg-\[var\(--snc-hover\)\]:hover{background-color:var(--snc-hover)}.hover\:bg-black:hover{background-color:#000}.hover\:bg-black\/5:hover{background-color:#0000000d;background-color:lab(0% 0 0/.05)}.hover\:bg-black\/5\!:hover{background-color:#0000000d!important;background-color:lab(0% 0 0/.05)!important}.hover\:bg-black\/10:hover{background-color:#0000001a;background-color:lab(0% 0 0/.1)}.hover\:bg-black\/10\!:hover{background-color:#0000001a!important;background-color:lab(0% 0 0/.1)!important}.hover\:bg-black\/25:hover{background-color:#00000040;background-color:lab(0% 0 0/.25)}.hover\:bg-black\/35:hover{background-color:#00000059;background-color:lab(0% 0 0/.35)}.hover\:bg-black\/50:hover{background-color:#00000080;background-color:lab(0% 0 0/.5)}.hover\:bg-black\/60:hover{background-color:#0009;background-color:lab(0% 0 0/.6)}.hover\:bg-black\/80:hover{background-color:#000c;background-color:lab(0% 0 0/.8)}.hover\:bg-black\/90:hover{background-color:#000000e6;background-color:lab(0% 0 0/.9)}.hover\:bg-blue-50:hover{background-color:var(--blue-50)}.hover\:bg-blue-100:hover,.hover\:bg-blue-100\/80:hover{background-color:var(--blue-100)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-100\/80:hover{background-color:color-mix(in oklab,var(--blue-100)80%,transparent)}}.hover\:bg-blue-400\/20\!:hover{background-color:var(--blue-400)!important}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-400\/20\!:hover{background-color:color-mix(in oklab,var(--blue-400)20%,transparent)!important}}.hover\:bg-blue-600:hover{background-color:var(--blue-600)}.hover\:bg-blue-700:hover{background-color:var(--blue-700)}.hover\:bg-blue-800:hover{background-color:var(--blue-800)}.hover\:bg-gray-50:hover{background-color:var(--gray-50)}.hover\:bg-gray-100:hover,.hover\:bg-gray-100\/75:hover{background-color:var(--gray-100)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-100\/75:hover{background-color:color-mix(in oklab,var(--gray-100)75%,transparent)}}.hover\:bg-gray-200:hover{background-color:var(--gray-200)}.hover\:bg-gray-300:hover{background-color:var(--gray-300)}.hover\:bg-gray-500:hover{background-color:var(--gray-500)}.hover\:bg-gray-800:hover,.hover\:bg-gray-800\/10:hover{background-color:var(--gray-800)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-800\/10:hover{background-color:color-mix(in oklab,var(--gray-800)10%,transparent)}}.hover\:bg-gray-900\/30:hover{background-color:var(--gray-900)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-900\/30:hover{background-color:color-mix(in oklab,var(--gray-900)30%,transparent)}}.hover\:bg-gray-solid-1000\/90:hover{background-color:#0d0d0de6;background-color:lab(3.63549% -.00000745058 .00000298023/.9)}.hover\:bg-green-100:hover{background-color:var(--green-100)}.hover\:bg-orange-400\/10:hover{background-color:var(--orange-400)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-orange-400\/10:hover{background-color:color-mix(in oklab,var(--orange-400)10%,transparent)}}.hover\:bg-orange-400\/20:hover{background-color:var(--orange-400)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-orange-400\/20:hover{background-color:color-mix(in oklab,var(--orange-400)20%,transparent)}}.hover\:bg-orange-500:hover{background-color:var(--orange-500)}.hover\:bg-purple-100:hover{background-color:var(--purple-100)}.hover\:bg-purple-600:hover{background-color:var(--purple-600)}.hover\:bg-red-100:hover{background-color:var(--red-100)}.hover\:bg-red-500:hover,.hover\:bg-red-500\/10:hover{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-500\/10:hover{background-color:color-mix(in oklab,var(--red-500)10%,transparent)}}.hover\:bg-red-500\/15:hover{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-500\/15:hover{background-color:color-mix(in oklab,var(--red-500)15%,transparent)}}.hover\:bg-red-500\/20:hover{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-500\/20:hover{background-color:color-mix(in oklab,var(--red-500)20%,transparent)}}.hover\:bg-red-500\/30:hover{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-500\/30:hover{background-color:color-mix(in oklab,var(--red-500)30%,transparent)}}.hover\:bg-red-600:hover{background-color:var(--red-600)}.hover\:bg-red-700:hover{background-color:var(--red-700)}.hover\:bg-token-bg-elevated-primary:hover{background-color:var(--bg-elevated-primary)}.hover\:bg-token-bg-elevated-secondary:hover,.hover\:bg-token-bg-elevated-secondary\/40:hover{background-color:var(--bg-elevated-secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-elevated-secondary\/40:hover{background-color:color-mix(in oklab,var(--bg-elevated-secondary)40%,transparent)}}.hover\:bg-token-bg-elevated-secondary\/80:hover{background-color:var(--bg-elevated-secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-elevated-secondary\/80:hover{background-color:color-mix(in oklab,var(--bg-elevated-secondary)80%,transparent)}}.hover\:bg-token-bg-primary:hover{background-color:var(--bg-primary)}.hover\:bg-token-bg-primary\!:hover{background-color:var(--bg-primary)!important}.hover\:bg-token-bg-primary\/10:hover{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-primary\/10:hover{background-color:color-mix(in oklab,var(--bg-primary)10%,transparent)}}.hover\:bg-token-bg-primary\/60:hover{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-primary\/60:hover{background-color:color-mix(in oklab,var(--bg-primary)60%,transparent)}}.hover\:bg-token-bg-primary\/70:hover{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-primary\/70:hover{background-color:color-mix(in oklab,var(--bg-primary)70%,transparent)}}.hover\:bg-token-bg-primary\/80:hover{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-primary\/80:hover{background-color:color-mix(in oklab,var(--bg-primary)80%,transparent)}}.hover\:bg-token-bg-secondary:hover,.hover\:bg-token-bg-secondary\/50:hover{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-secondary\/50:hover{background-color:color-mix(in oklab,var(--bg-secondary)50%,transparent)}}.hover\:bg-token-bg-secondary\/60:hover{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-secondary\/60:hover{background-color:color-mix(in oklab,var(--bg-secondary)60%,transparent)}}.hover\:bg-token-bg-secondary\/70:hover{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-secondary\/70:hover{background-color:color-mix(in oklab,var(--bg-secondary)70%,transparent)}}.hover\:bg-token-bg-tertiary:hover{background-color:var(--bg-tertiary)}.hover\:bg-token-bg-tertiary\!:hover{background-color:var(--bg-tertiary)!important}.hover\:bg-token-bg-tertiary\/10:hover{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-tertiary\/10:hover{background-color:color-mix(in oklab,var(--bg-tertiary)10%,transparent)}}.hover\:bg-token-bg-tertiary\/50:hover{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-tertiary\/50:hover{background-color:color-mix(in oklab,var(--bg-tertiary)50%,transparent)}}.hover\:bg-token-bg-tertiary\/60:hover{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-tertiary\/60:hover{background-color:color-mix(in oklab,var(--bg-tertiary)60%,transparent)}}.hover\:bg-token-bg-tertiary\/70:hover{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-bg-tertiary\/70:hover{background-color:color-mix(in oklab,var(--bg-tertiary)70%,transparent)}}.hover\:bg-token-border-default:hover{background-color:var(--border-default)}.hover\:bg-token-border-light:hover{background-color:var(--border-light)}.hover\:bg-token-border-xlight:hover{background-color:var(--border-xlight)}.hover\:bg-token-icon-accent\/20:hover{background-color:var(--icon-accent)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-icon-accent\/20:hover{background-color:color-mix(in oklab,var(--icon-accent)20%,transparent)}}.hover\:bg-token-icon-surface\/10:hover{background-color:rgb(var(--icon-surface)/1)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-icon-surface\/10:hover{background-color:color-mix(in oklab,rgb(var(--icon-surface)/1)10%,transparent)}}.hover\:bg-token-interactive-bg-accent-muted-hover:hover{background-color:var(--interactive-bg-accent-muted-hover)}.hover\:bg-token-interactive-bg-primary-default:hover{background-color:var(--interactive-bg-primary-default)}.hover\:bg-token-interactive-bg-primary-hover:hover,.hover\:bg-token-interactive-bg-primary-hover\/10:hover{background-color:var(--interactive-bg-primary-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-interactive-bg-primary-hover\/10:hover{background-color:color-mix(in oklab,var(--interactive-bg-primary-hover)10%,transparent)}}.hover\:bg-token-interactive-bg-primary-selected\/5:hover{background-color:var(--interactive-bg-primary-selected)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-interactive-bg-primary-selected\/5:hover{background-color:color-mix(in oklab,var(--interactive-bg-primary-selected)5%,transparent)}}.hover\:bg-token-interactive-bg-secondary-hover:hover,.hover\:bg-token-interactive-bg-secondary-hover\/60:hover{background-color:var(--interactive-bg-secondary-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-interactive-bg-secondary-hover\/60:hover{background-color:color-mix(in oklab,var(--interactive-bg-secondary-hover)60%,transparent)}}.hover\:bg-token-interactive-bg-secondary-selected:hover{background-color:var(--interactive-bg-secondary-selected)}.hover\:bg-token-interactive-bg-tertiary-default:hover{background-color:var(--interactive-bg-tertiary-default)}.hover\:bg-token-interactive-bg-tertiary-hover:hover{background-color:var(--interactive-bg-tertiary-hover)}.hover\:bg-token-interactive-bg-tertiary-press:hover{background-color:var(--interactive-bg-tertiary-press)}.hover\:bg-token-main-surface-primary:hover{background-color:var(--main-surface-primary)}.hover\:bg-token-main-surface-primary\!:hover{background-color:var(--main-surface-primary)!important}.hover\:bg-token-main-surface-primary-inverse:hover{background-color:var(--main-surface-primary-inverse)}.hover\:bg-token-main-surface-primary\/30\!:hover{background-color:var(--main-surface-primary)!important}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-main-surface-primary\/30\!:hover{background-color:color-mix(in oklab,var(--main-surface-primary)30%,transparent)!important}}.hover\:bg-token-main-surface-primary\/70:hover{background-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-main-surface-primary\/70:hover{background-color:color-mix(in oklab,var(--main-surface-primary)70%,transparent)}}.hover\:bg-token-main-surface-secondary:hover{background-color:var(--main-surface-secondary)}.hover\:bg-token-main-surface-secondary\!:hover{background-color:var(--main-surface-secondary)!important}.hover\:bg-token-main-surface-secondary-selected:hover{background-color:var(--main-surface-secondary-selected)}.hover\:bg-token-main-surface-secondary\/80:hover{background-color:var(--main-surface-secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-main-surface-secondary\/80:hover{background-color:color-mix(in oklab,var(--main-surface-secondary)80%,transparent)}}.hover\:bg-token-main-surface-tertiary:hover{background-color:var(--main-surface-tertiary)}.hover\:bg-token-sidebar-surface-secondary:hover{background-color:var(--sidebar-surface-secondary)}.hover\:bg-token-sidebar-surface-tertiary:hover{background-color:var(--sidebar-surface-tertiary)}.hover\:bg-token-surface-error\/10:hover{background-color:rgb(var(--surface-error)/1)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-token-surface-error\/10:hover{background-color:color-mix(in oklab,rgb(var(--surface-error)/1)10%,transparent)}}.hover\:bg-token-surface-hover:hover{background-color:var(--surface-hover)}.hover\:bg-token-text-primary:hover{background-color:var(--text-primary)}.hover\:bg-token-text-primary\!:hover{background-color:var(--text-primary)!important}.hover\:bg-token-text-secondary:hover{background-color:var(--text-secondary)}.hover\:bg-transparent:hover{background-color:#0000}.hover\:bg-transparent\!:hover{background-color:#0000!important}.hover\:bg-white:hover{background-color:#fff}.hover\:bg-white\/10:hover{background-color:#ffffff1a;background-color:lab(100% -.0000298023 .0000119209/.1)}.hover\:bg-white\/15:hover{background-color:#ffffff26;background-color:lab(100% -.0000298023 .0000119209/.15)}.hover\:bg-white\/20:hover{background-color:#fff3;background-color:lab(100% -.0000298023 .0000119209/.2)}.hover\:bg-white\/25:hover{background-color:#ffffff40;background-color:lab(100% -.0000298023 .0000119209/.25)}.hover\:bg-white\/25\!:hover{background-color:#ffffff40!important;background-color:lab(100% -.0000298023 .0000119209/.25)!important}.hover\:bg-white\/30:hover{background-color:#ffffff4d;background-color:lab(100% -.0000298023 .0000119209/.3)}.hover\:bg-white\/40:hover{background-color:#fff6;background-color:lab(100% -.0000298023 .0000119209/.4)}.hover\:bg-white\/60:hover{background-color:#fff9;background-color:lab(100% -.0000298023 .0000119209/.6)}.hover\:bg-white\/90:hover{background-color:#ffffffe6;background-color:lab(100% -.0000298023 .0000119209/.9)}.hover\:bg-yellow-400:hover{background-color:var(--yellow-400)}.hover\:from-\[\#4636f8\]:hover{--tw-gradient-from:#4636f8;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.hover\:to-\[\#951ce8\]:hover{--tw-gradient-to:#951ce8;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.hover\:fill-token-text-primary:hover{fill:var(--text-primary)}.hover\:entity-accent:hover{color:var(--theme-entity-accent)}.hover\:entity-accent\!:hover{color:var(--theme-entity-accent)!important}.hover\:text-\(--super-widget-link-color-hover\):hover{color:var(--super-widget-link-color-hover)}.hover\:text-\[\#174ea6\]:hover{color:#174ea6}.hover\:text-\[\#0285ff\]\/80:hover{color:#0285ffcc;color:lab(54.959% 5.86918 -70.2582/.8)}.hover\:text-\[rgb\(11\,76\,140\)\]:hover{color:#0b4c8c}.hover\:text-black\/60:hover{color:#0009;color:lab(0% 0 0/.6)}.hover\:text-blue-200:hover{color:var(--blue-200)}.hover\:text-blue-300:hover{color:var(--blue-300)}.hover\:text-blue-500:hover{color:var(--blue-500)}.hover\:text-blue-600:hover{color:var(--blue-600)}.hover\:text-blue-700:hover{color:var(--blue-700)}.hover\:text-gray-600:hover{color:var(--gray-600)}.hover\:text-gray-700:hover{color:var(--gray-700)}.hover\:text-gray-900:hover{color:var(--gray-900)}.hover\:text-inherit:hover{color:inherit}.hover\:text-red-300:hover{color:var(--red-300)}.hover\:text-red-500:hover{color:var(--red-500)}.hover\:text-red-700:hover{color:var(--red-700)}.hover\:text-red-800:hover{color:var(--red-800)}.hover\:text-token-icon-accent:hover{color:var(--icon-accent)}.hover\:text-token-icon-primary:hover{color:var(--icon-primary)}.hover\:text-token-interactive-label-accent-hover:hover{color:var(--interactive-label-accent-hover)}.hover\:text-token-link-hover:hover{color:var(--link-hover)}.hover\:text-token-main-surface-tertiary:hover{color:var(--main-surface-tertiary)}.hover\:text-token-text-accent:hover,.hover\:text-token-text-accent\/80:hover{color:var(--text-accent)}@supports (color:color-mix(in lab, red, red)){.hover\:text-token-text-accent\/80:hover{color:color-mix(in oklab,var(--text-accent)80%,transparent)}}.hover\:text-token-text-inverted:hover{color:var(--text-inverted)}.hover\:text-token-text-primary:hover{color:var(--text-primary)}.hover\:text-token-text-primary\!:hover{color:var(--text-primary)!important}.hover\:text-token-text-primary\/44:hover{color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.hover\:text-token-text-primary\/44:hover{color:color-mix(in oklab,var(--text-primary)44%,transparent)}}.hover\:text-token-text-primary\/60:hover{color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.hover\:text-token-text-primary\/60:hover{color:color-mix(in oklab,var(--text-primary)60%,transparent)}}.hover\:text-token-text-primary\/80:hover{color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.hover\:text-token-text-primary\/80:hover{color:color-mix(in oklab,var(--text-primary)80%,transparent)}}.hover\:text-token-text-secondary:hover{color:var(--text-secondary)}.hover\:text-token-text-tertiary:hover{color:var(--text-tertiary)}.hover\:text-white:hover{color:#fff}.hover\:text-white\/40:hover{color:#fff6;color:lab(100% -.0000298023 .0000119209/.4)}.hover\:text-white\/70:hover{color:#ffffffb3;color:lab(100% -.0000298023 .0000119209/.7)}.hover\:text-white\/90:hover{color:#ffffffe6;color:lab(100% -.0000298023 .0000119209/.9)}.hover\:no-underline:hover{-webkit-text-decoration-line:none;text-decoration-line:none}.hover\:underline:hover{-webkit-text-decoration-line:underline;text-decoration-line:underline}.hover\:underline\!:hover{-webkit-text-decoration-line:underline!important;text-decoration-line:underline!important}.hover\:decoration-token-text-primary:hover{-webkit-text-decoration-color:var(--text-primary);-webkit-text-decoration-color:var(--text-primary);-webkit-text-decoration-color:var(--text-primary);-webkit-text-decoration-color:var(--text-primary);text-decoration-color:var(--text-primary)}.hover\:opacity-60:hover{opacity:.6}.hover\:opacity-65:hover{opacity:.65}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-75:hover{opacity:.75}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-95:hover{opacity:.95}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-100\!:hover{opacity:1!important}.hover\:mix-blend-normal:hover{mix-blend-mode:normal}.hover\:shadow-\[-1px_0_2px_2px_rgba\(255\,0\,0\,0\.4\)\]:hover{--tw-shadow:-1px 0 2px 2px var(--tw-shadow-color,#f006);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:shadow-\[0px_0px_1px_0px_\#0000004D\,_0px_4px_4px_0px_\#0000000A\]:hover{--tw-shadow:0px 0px 1px 0px var(--tw-shadow-color,#0000004d),0px 4px 4px 0px var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:shadow-\[0px_4px_16px_0px_rgba\(0\,0\,0\,0\.05\)\]:hover{--tw-shadow:0px 4px 16px 0px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:shadow-none:hover{--tw-shadow:0 0 transparent;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:ring-1:hover{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:shadow-token-border-default:hover{--tw-shadow-color:var(--border-default)}@supports (color:color-mix(in lab, red, red)){.hover\:shadow-token-border-default:hover{--tw-shadow-color:color-mix(in oklab,var(--border-default)var(--tw-shadow-alpha),transparent)}}.hover\:ring-black\/10:hover{--tw-ring-color:#0000001a}@supports (color:lab(0% 0 0)){.hover\:ring-black\/10:hover{--tw-ring-color:lab(0% 0 0/.1)}}.hover\:ring-white\/30:hover{--tw-ring-color:#ffffff4d}@supports (color:lab(0% 0 0)){.hover\:ring-white\/30:hover{--tw-ring-color:lab(100% -.0000298023 .0000119209/.3)}}.hover\:outline:hover,.hover\:outline-\[1px\]:hover{outline-style:var(--tw-outline-style);outline-width:1px}.hover\:outline-blue-100:hover{outline-color:var(--blue-100)}.hover\:brightness-105:hover{--tw-brightness:brightness(105%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.hover\:brightness-\[0\.98\]:hover{--tw-brightness:brightness(.98);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.hover\:backdrop-blur-md:hover{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.hover\:transition-none:hover{transition-property:none}.hover\:delay-0:hover{transition-delay:0s}.hover\:delay-300:hover{transition-delay:.3s}.hover\:file\:bg-token-bg-tertiary:hover::file-selector-button{background-color:var(--bg-tertiary)}.hover\:before\:opacity-100:hover:before{content:var(--tw-content);opacity:1}.hover\:after\:bg-token-main-surface-tertiary:hover:after{content:var(--tw-content);background-color:var(--main-surface-tertiary)}}.focus\:pointer-events-auto:focus{pointer-events:auto}.focus\:not-sr-only:focus{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.focus\:absolute:focus{position:absolute}.focus\:top-4:focus{top:calc(var(--spacing)*4)}.focus\:left-4:focus{left:calc(var(--spacing)*4)}.focus\:z-50:focus{z-index:50}.focus\:border-0:focus{border-style:var(--tw-border-style);border-width:0}.focus\:border-none:focus{--tw-border-style:none;border-style:none}.focus\:border-none\!:focus{--tw-border-style:none!important;border-style:none!important}.focus\:border-\[var\(--interactive-border-focus\)\]:focus{border-color:var(--interactive-border-focus)}.focus\:border-black:focus{border-color:#000}.focus\:border-blue-500:focus{border-color:var(--blue-500)}.focus\:border-gray-200:focus{border-color:var(--gray-200)}.focus\:border-gray-400:focus{border-color:var(--gray-400)}.focus\:border-gray-500:focus{border-color:var(--gray-500)}.focus\:border-orange-400:focus{border-color:var(--orange-400)}.focus\:border-red-500:focus{border-color:var(--red-500)}.focus\:border-token-border-heavy:focus{border-color:var(--border-heavy)}.focus\:border-token-border-xheavy:focus{border-color:var(--border-xheavy)}.focus\:border-token-icon-accent:focus{border-color:var(--icon-accent)}.focus\:border-token-interactive-border-focus:focus{border-color:var(--interactive-border-focus)}.focus\:border-token-text-error:focus{border-color:var(--text-error)}.focus\:border-token-text-primary:focus,.focus\:border-token-text-primary\/44:focus{border-color:var(--text-primary)}@supports (color:color-mix(in lab, red, red)){.focus\:border-token-text-primary\/44:focus{border-color:color-mix(in oklab,var(--text-primary)44%,transparent)}}.focus\:border-token-text-tertiary:focus{border-color:var(--text-tertiary)}.focus\:border-transparent:focus{border-color:#0000}.focus\:border-white\/40:focus{border-color:#fff6;border-color:lab(100% -.0000298023 .0000119209/.4)}.focus\:bg-gray-950\!:focus{background-color:var(--gray-950)!important}.focus\:bg-token-bg-primary:focus{background-color:var(--bg-primary)}.focus\:bg-token-bg-secondary:focus{background-color:var(--bg-secondary)}.focus\:bg-token-icon-surface\/10:focus{background-color:rgb(var(--icon-surface)/1)}@supports (color:color-mix(in lab, red, red)){.focus\:bg-token-icon-surface\/10:focus{background-color:color-mix(in oklab,rgb(var(--icon-surface)/1)10%,transparent)}}.focus\:bg-token-main-surface-secondary:focus{background-color:var(--main-surface-secondary)}.focus\:bg-transparent:focus{background-color:#0000}.focus\:bg-white:focus{background-color:#fff}.focus\:bg-white\/15:focus{background-color:#ffffff26;background-color:lab(100% -.0000298023 .0000119209/.15)}.focus\:p-4:focus{padding:calc(var(--spacing)*4)}.focus\:text-token-text-primary:focus{color:var(--text-primary)}.focus\:text-token-text-secondary:focus{color:var(--text-secondary)}.focus\:opacity-100:focus{opacity:1}.focus\:shadow-none:focus{--tw-shadow:0 0 transparent;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:shadow-none\!:focus{--tw-shadow:0 0 transparent!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-0\!:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor)!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-4:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(4px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-\[\#5856D6\]:focus{--tw-ring-color:#5856d6}.focus\:ring-\[black\]\!:focus{--tw-ring-color:black!important}.focus\:ring-black:focus{--tw-ring-color:#000}.focus\:ring-blue-400:focus{--tw-ring-color:var(--blue-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--blue-500)}.focus\:ring-gray-200:focus{--tw-ring-color:var(--gray-200)}.focus\:ring-gray-500:focus{--tw-ring-color:var(--gray-500)}.focus\:ring-purple-500:focus{--tw-ring-color:var(--purple-500)}.focus\:ring-red-500:focus{--tw-ring-color:var(--red-500)}.focus\:ring-token-border-heavy:focus{--tw-ring-color:var(--border-heavy)}.focus\:ring-token-border-light:focus{--tw-ring-color:var(--border-light)}.focus\:ring-token-border-xheavy:focus{--tw-ring-color:var(--border-xheavy)}.focus\:ring-token-icon-accent:focus{--tw-ring-color:var(--icon-accent)}.focus\:ring-token-text-primary:focus{--tw-ring-color:var(--text-primary)}.focus\:ring-token-text-secondary:focus{--tw-ring-color:var(--text-secondary)}.focus\:ring-transparent:focus{--tw-ring-color:transparent}.focus\:ring-white:focus{--tw-ring-color:#fff}.focus\:ring-white\/50:focus{--tw-ring-color:#ffffff80}@supports (color:lab(0% 0 0)){.focus\:ring-white\/50:focus{--tw-ring-color:lab(100% -.0000298023 .0000119209/.5)}}.focus\:ring-white\/70:focus{--tw-ring-color:#ffffffb3}@supports (color:lab(0% 0 0)){.focus\:ring-white\/70:focus{--tw-ring-color:lab(100% -.0000298023 .0000119209/.7)}}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:ring-offset-0\!:focus{--tw-ring-offset-width:0px!important;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)!important}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:ring-offset-token-bg-primary:focus{--tw-ring-offset-color:var(--bg-primary)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\:outline:focus{outline-style:var(--tw-outline-style);outline-width:1px}.focus\:outline\!:focus{outline-style:var(--tw-outline-style)!important;outline-width:1px!important}.focus\:outline-0:focus{outline-style:var(--tw-outline-style);outline-width:0}.focus\:outline-0\!:focus{outline-style:var(--tw-outline-style)!important;outline-width:0!important}.focus\:outline-1:focus{outline-style:var(--tw-outline-style);outline-width:1px}.focus\:outline-1\!:focus{outline-style:var(--tw-outline-style)!important;outline-width:1px!important}.focus\:outline-\[1\.5px\]:focus{outline-style:var(--tw-outline-style);outline-width:1.5px}.focus\:outline-offset-\[2\.5px\]:focus{outline-offset:2.5px}.focus\:outline-black\!:focus{outline-color:#000!important}.focus\:outline-token-text-primary:focus{outline-color:var(--text-primary)}.focus\:outline-white\!:focus{outline-color:#fff!important}.focus\:backdrop-blur-md:focus{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\:outline-none\!:focus{--tw-outline-style:none!important;outline-style:none!important}.focus\:outline-solid:focus{--tw-outline-style:solid;outline-style:solid}.focus\:ring-inset:focus{--tw-ring-inset:inset}.focus-visible\:pointer-events-auto:focus-visible{pointer-events:auto}.focus-visible\:translate-y-0:focus-visible{--tw-translate-y:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.focus-visible\:rounded-\[26px\]:focus-visible{border-radius:26px}.focus-visible\:border-0:focus-visible{border-style:var(--tw-border-style);border-width:0}.focus-visible\:border-none:focus-visible{--tw-border-style:none;border-style:none}.focus-visible\:border-black:focus-visible{border-color:#000}.focus-visible\:border-token-border-default:focus-visible{border-color:var(--border-default)}.focus-visible\:border-token-border-heavy:focus-visible{border-color:var(--border-heavy)}.focus-visible\:bg-black\/25:focus-visible{background-color:#00000040;background-color:lab(0% 0 0/.25)}.focus-visible\:bg-black\/35:focus-visible{background-color:#00000059;background-color:lab(0% 0 0/.35)}.focus-visible\:bg-token-bg-tertiary:focus-visible,.focus-visible\:bg-token-bg-tertiary\/50:focus-visible{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:bg-token-bg-tertiary\/50:focus-visible{background-color:color-mix(in oklab,var(--bg-tertiary)50%,transparent)}}.focus-visible\:bg-token-icon-surface\/10:focus-visible{background-color:rgb(var(--icon-surface)/1)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:bg-token-icon-surface\/10:focus-visible{background-color:color-mix(in oklab,rgb(var(--icon-surface)/1)10%,transparent)}}.focus-visible\:bg-token-interactive-bg-secondary-hover:focus-visible{background-color:var(--interactive-bg-secondary-hover)}.focus-visible\:bg-token-surface-hover:focus-visible{background-color:var(--surface-hover)}.focus-visible\:bg-token-text-secondary\!:focus-visible{background-color:var(--text-secondary)!important}.focus-visible\:bg-transparent:focus-visible{background-color:#0000}.focus-visible\:bg-transparent\!:focus-visible{background-color:#0000!important}.focus-visible\:bg-white\/20:focus-visible{background-color:#fff3;background-color:lab(100% -.0000298023 .0000119209/.2)}.focus-visible\:text-token-text-primary:focus-visible{color:var(--text-primary)}.focus-visible\:no-underline:focus-visible{-webkit-text-decoration-line:none;text-decoration-line:none}.focus-visible\:underline:focus-visible{-webkit-text-decoration-line:underline;text-decoration-line:underline}.focus-visible\:opacity-100:focus-visible{opacity:1}.focus-visible\:ring:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-4:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(4px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[\#5856D6\]:focus-visible{--tw-ring-color:#5856d6}.focus-visible\:ring-\[color\:var\(--accentStroke\)\]:focus-visible{--tw-ring-color:var(--accentStroke)}.focus-visible\:ring-black:focus-visible{--tw-ring-color:#000}.focus-visible\:ring-blue-500:focus-visible,.focus-visible\:ring-blue-500\/60:focus-visible{--tw-ring-color:var(--blue-500)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-blue-500\/60:focus-visible{--tw-ring-color:color-mix(in oklab,var(--blue-500)60%,transparent)}}.focus-visible\:ring-current:focus-visible{--tw-ring-color:currentcolor}.focus-visible\:ring-red-500\/20:focus-visible{--tw-ring-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-red-500\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--red-500)20%,transparent)}}.focus-visible\:ring-token-border-default:focus-visible{--tw-ring-color:var(--border-default)}.focus-visible\:ring-token-border-heavy:focus-visible{--tw-ring-color:var(--border-heavy)}.focus-visible\:ring-token-border-medium:focus-visible{--tw-ring-color:var(--border-medium)}.focus-visible\:ring-token-text-primary:focus-visible{--tw-ring-color:var(--text-primary)}.focus-visible\:ring-token-text-quaternary:focus-visible{--tw-ring-color:var(--text-quaternary)}.focus-visible\:ring-token-text-secondary:focus-visible{--tw-ring-color:var(--text-secondary)}.focus-visible\:ring-token-text-tertiary:focus-visible{--tw-ring-color:var(--text-tertiary)}.focus-visible\:ring-white\/40:focus-visible{--tw-ring-color:#fff6}@supports (color:lab(0% 0 0)){.focus-visible\:ring-white\/40:focus-visible{--tw-ring-color:lab(100% -.0000298023 .0000119209/.4)}}.focus-visible\:ring-white\/70:focus-visible{--tw-ring-color:#ffffffb3}@supports (color:lab(0% 0 0)){.focus-visible\:ring-white\/70:focus-visible{--tw-ring-color:lab(100% -.0000298023 .0000119209/.7)}}.focus-visible\:ring-white\/80:focus-visible{--tw-ring-color:#fffc}@supports (color:lab(0% 0 0)){.focus-visible\:ring-white\/80:focus-visible{--tw-ring-color:lab(100% -.0000298023 .0000119209/.8)}}.focus-visible\:ring-offset-1:focus-visible{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:ring-offset-token-bg-primary:focus-visible{--tw-ring-offset-color:var(--bg-primary)}.focus-visible\:ring-offset-transparent:focus-visible{--tw-ring-offset-color:transparent}.focus-visible\:outline-hidden:focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus-visible\:outline-hidden:focus-visible{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:focus-ring:focus-visible{outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}.focus-visible\:outline:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-0:focus-visible{outline-style:var(--tw-outline-style);outline-width:0}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-2:focus-visible,.focus-visible\:outline-\[2px\]:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:\[outline-width\:1\.5px\]:focus-visible{outline-width:1.5px}.focus-visible\:-outline-offset-1:focus-visible{outline-offset:calc(1px*-1)}.focus-visible\:\[outline-offset\:2\.5px\]:focus-visible{outline-offset:2.5px}.focus-visible\:outline-offset-0:focus-visible{outline-offset:0px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-offset-\[-2px\]:focus-visible{outline-offset:-2px}.focus-visible\:outline-offset-\[-8px\]:focus-visible{outline-offset:-8px}.focus-visible\:outline-offset-\[2\.5px\]:focus-visible{outline-offset:2.5px}.focus-visible\:\[outline-color\:var\(--text-primary\)\]:focus-visible{outline-color:var(--text-primary)}.focus-visible\:outline-black:focus-visible{outline-color:#000}.focus-visible\:outline-current:focus-visible{outline-color:currentColor}.focus-visible\:outline-orange-500:focus-visible{outline-color:var(--orange-500)}.focus-visible\:outline-token-border-heavy:focus-visible{outline-color:var(--border-heavy)}.focus-visible\:outline-token-interactive-label-accent-default:focus-visible{outline-color:var(--interactive-label-accent-default)}.focus-visible\:outline-token-text-primary:focus-visible{outline-color:var(--text-primary)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.focus-visible\:outline-solid:focus-visible{--tw-outline-style:solid;outline-style:solid}.focus-visible\:\[outline-style\:solid\]:focus-visible{outline-style:solid}.group-focus-within\/dialog\:focus-visible\:\[outline-width\:1\.5px\]:is(:where(.group\/dialog):focus-within *):focus-visible{outline-width:1.5px}.group-focus-within\/dialog\:focus-visible\:\[outline-offset\:2\.5px\]:is(:where(.group\/dialog):focus-within *):focus-visible{outline-offset:2.5px}.group-focus-within\/dialog\:focus-visible\:\[outline-color\:var\(--text-primary\)\]:is(:where(.group\/dialog):focus-within *):focus-visible{outline-color:var(--text-primary)}.group-focus-within\/dialog\:focus-visible\:\[outline-style\:solid\]:is(:where(.group\/dialog):focus-within *):focus-visible{outline-style:solid}.focus-visible\:before\:opacity-100:focus-visible:before,.focus-visible\:after\:opacity-100:focus-visible:after{content:var(--tw-content);opacity:1}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x)var(--tw-scale-y)}.active\:scale-100:active{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.active\:scale-115:active{--tw-scale-x:115%;--tw-scale-y:115%;--tw-scale-z:115%;scale:var(--tw-scale-x)var(--tw-scale-y)}.active\:scale-\[0\.9\]:active{scale:.9}.active\:cursor-grabbing:active{cursor:grabbing}.active\:bg-\[\#f0d35a\]:active{background-color:#f0d35a}.active\:bg-black\/8:active{background-color:#00000014;background-color:lab(0% 0 0/.08)}.active\:bg-black\/10:active{background-color:#0000001a;background-color:lab(0% 0 0/.1)}.active\:bg-black\/10\!:active{background-color:#0000001a!important;background-color:lab(0% 0 0/.1)!important}.active\:bg-black\/20:active{background-color:#0003;background-color:lab(0% 0 0/.2)}.active\:bg-gray-300:active{background-color:var(--gray-300)}.active\:bg-gray-700:active{background-color:var(--gray-700)}.active\:bg-red-500\/20:active{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.active\:bg-red-500\/20:active{background-color:color-mix(in oklab,var(--red-500)20%,transparent)}}.active\:bg-token-bg-tertiary:active{background-color:var(--bg-tertiary)}.active\:bg-token-border-default:active{background-color:var(--border-default)}.active\:bg-token-interactive-bg-accent-muted-press:active{background-color:var(--interactive-bg-accent-muted-press)}.active\:bg-token-interactive-bg-secondary-press:active{background-color:var(--interactive-bg-secondary-press)}.active\:bg-token-interactive-bg-tertiary-press:active{background-color:var(--interactive-bg-tertiary-press)}.active\:bg-token-main-surface-primary\/80:active{background-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.active\:bg-token-main-surface-primary\/80:active{background-color:color-mix(in oklab,var(--main-surface-primary)80%,transparent)}}.active\:bg-transparent:active{background-color:#0000}.active\:bg-transparent\!:active{background-color:#0000!important}.active\:opacity-1:active{opacity:.01}.active\:opacity-50:active{opacity:.5}.active\:opacity-75:active{opacity:.75}.active\:opacity-100:active{opacity:1}.active\:opacity-100\!:active{opacity:1!important}.active\:brightness-95:active{--tw-brightness:brightness(95%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.active\:select-auto:active{-webkit-user-select:auto;user-select:auto}@media (hover:hover){.enabled\:hover\:bg-black\/5:enabled:hover{background-color:#0000000d;background-color:lab(0% 0 0/.05)}.enabled\:hover\:bg-token-bg-tertiary\/60:enabled:hover{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.enabled\:hover\:bg-token-bg-tertiary\/60:enabled:hover{background-color:color-mix(in oklab,var(--bg-tertiary)60%,transparent)}}.enabled\:hover\:bg-token-interactive-bg-secondary-hover:enabled:hover{background-color:var(--interactive-bg-secondary-hover)}.enabled\:hover\:bg-token-main-surface-secondary:enabled:hover{background-color:var(--main-surface-secondary)}.enabled\:hover\:bg-token-surface-hover:enabled:hover{background-color:var(--surface-hover)}.enabled\:hover\:text-token-text-secondary:enabled:hover{color:var(--text-secondary)}.enabled\:hover\:underline:enabled:hover{-webkit-text-decoration-line:underline;text-decoration-line:underline}}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-auto:disabled{cursor:auto}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-default\!:disabled{cursor:default!important}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-\[\#D7D7D7\]:disabled{background-color:#d7d7d7}.disabled\:bg-gray-200:disabled{background-color:var(--gray-200)}.disabled\:bg-token-bg-primary:disabled{background-color:var(--bg-primary)}.disabled\:bg-token-bg-tertiary:disabled{background-color:var(--bg-tertiary)}.disabled\:bg-token-main-surface-secondary:disabled{background-color:var(--main-surface-secondary)}.disabled\:bg-token-main-surface-tertiary:disabled{background-color:var(--main-surface-tertiary)}.disabled\:bg-transparent:disabled{background-color:#0000}.disabled\:text-\[\#f4f4f4\]:disabled{color:#f4f4f4}.disabled\:text-gray-50:disabled{color:var(--gray-50)}.disabled\:text-gray-500:disabled{color:var(--gray-500)}.disabled\:text-token-border-medium:disabled{color:var(--border-medium)}.disabled\:text-token-text-quaternary:disabled{color:var(--text-quaternary)}.disabled\:text-token-text-tertiary:disabled{color:var(--text-tertiary)}.disabled\:opacity-25:disabled{opacity:.25}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.disabled\:opacity-70:disabled{opacity:.7}.disabled\:opacity-75:disabled{opacity:.75}.disabled\:opacity-100:disabled{opacity:1}.disabled\:opacity-100\!:disabled{opacity:1!important}@media (hover:hover){.disabled\:hover\:bg-token-bg-primary:disabled:hover{background-color:var(--bg-primary)}.disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}.disabled\:hover\:text-token-text-secondary:disabled:hover{color:var(--text-secondary)}}.disabled\:focus\:border-gray-200:disabled:focus{border-color:var(--gray-200)}.disabled\:focus\:ring-transparent:disabled:focus{--tw-ring-color:transparent}.has-focus-visible\:border-token-border-xheavy:has(:focus-visible){border-color:var(--border-xheavy)}.has-data-has-thread-error\:pt-2:has([data-has-thread-error]){padding-top:calc(var(--spacing)*2)}.has-data-has-thread-error\:\[box-shadow\:var\(--sharp-edge-bottom-shadow\)\]:has([data-has-thread-error]){box-shadow:var(--sharp-edge-bottom-shadow)}.has-data-writing-block\:pointer-events-none:has([data-writing-block]){pointer-events:none}.has-data-writing-block\:-mt-\(--shadow-height\):has([data-writing-block]){margin-top:calc(var(--shadow-height)*-1)}.has-data-writing-block\:pt-\(--shadow-height\):has([data-writing-block]){padding-top:var(--shadow-height)}.has-data-\[state\=open\]\:pointer-events-auto:has([data-state=open]){pointer-events:auto}.has-data-\[state\=open\]\:\[mask-position\:0_0\]:has([data-state=open]){-webkit-mask-position:0 0;mask-position:0 0}.has-data-\[state\=open\]\:opacity-100:has([data-state=open]){opacity:1}.has-\[\.action-details\:hover\]\:bg-token-bg-primary:has(.action-details:hover){background-color:var(--bg-primary)}.has-\[\.pulse-card-body\:hover\]\:cursor-pointer:has(.pulse-card-body:hover){cursor:pointer}.has-\[\.pulse-card-body\:hover\]\:bg-token-bg-tertiary:has(.pulse-card-body:hover){background-color:var(--bg-tertiary)}.has-\[a\:focus-visible\]\:outline-2:has(:is(a:focus-visible)){outline-style:var(--tw-outline-style);outline-width:2px}.has-\[button\:focus-visible\]\:z-10:has(:is(button:focus-visible)){z-index:10}.has-\[button\:focus-visible\]\:ring-2:has(:is(button:focus-visible)){--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.has-\[strong\]\:mb-0:has(:is(strong)){margin-bottom:calc(var(--spacing)*0)}.aria-checked\:bg-token-interactive-bg-primary-selected[aria-checked=true]{background-color:var(--interactive-bg-primary-selected)}.aria-checked\:text-token-interactive-label-primary-selected[aria-checked=true]{color:var(--interactive-label-primary-selected)}.data-active\:bg-transparent[data-active]{background-color:#0000}.data-border\:border-1[data-border]{border-style:var(--tw-border-style);border-width:1px}.data-border\:border-token-border-default[data-border]{border-color:var(--border-default)}.data-disabled\:cursor-not-allowed[data-disabled]{cursor:not-allowed}.data-disabled\:opacity-50[data-disabled]{opacity:.5}.data-has-range-start\:select-auto[data-has-range-start]{-webkit-user-select:auto;user-select:auto}.data-highlighted\:bg-transparent[data-highlighted]{background-color:#0000}@media (hover:hover){.data-no-hover-bg\:hover\:bg-transparent[data-no-hover-bg]:hover{background-color:#0000}}.data-placeholder\:text-token-text-tertiary[data-placeholder]{color:var(--text-tertiary)}.data-something\:bg-red-100[data-something]{background-color:var(--red-100)}.data-\[custom-padding\]\:py-0[data-custom-padding]{padding-block:calc(var(--spacing)*0)}.data-\[disabled\]\:cursor-not-allowed[data-disabled]{cursor:not-allowed}.data-\[disabled\]\:opacity-60[data-disabled]{opacity:.6}.data-\[focus-visible\]\:ring-2[data-focus-visible]{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}:is(.\*\*\:data-\[header-icon\]\:rotate-\[10deg\] *)[data-header-icon]{rotate:10deg}.data-\[state\=active\]\:border-b-2[data-state=active]{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.data-\[state\=active\]\:border-token-link-hover[data-state=active]{border-color:var(--link-hover)}.data-\[state\=active\]\:border-token-text-primary[data-state=active]{border-color:var(--text-primary)}.data-\[state\=active\]\:border-token-text-secondary[data-state=active]{border-color:var(--text-secondary)}.data-\[state\=active\]\:bg-token-bg-primary[data-state=active]{background-color:var(--bg-primary)}.data-\[state\=active\]\:bg-token-bg-tertiary[data-state=active]{background-color:var(--bg-tertiary)}.data-\[state\=active\]\:bg-token-interactive-bg-secondary-press[data-state=active]{background-color:var(--interactive-bg-secondary-press)}.data-\[state\=active\]\:text-token-interactive-label-secondary-default[data-state=active]{color:var(--interactive-label-secondary-default)}.data-\[state\=active\]\:text-token-link[data-state=active]{color:var(--link)}.data-\[state\=active\]\:text-token-text-primary[data-state=active]{color:var(--text-primary)}.data-\[state\=checked\]\:border[data-state=checked]{border-style:var(--tw-border-style);border-width:1px}.data-\[state\=checked\]\:border-2[data-state=checked]{border-style:var(--tw-border-style);border-width:2px}.data-\[state\=checked\]\:border-black[data-state=checked]{border-color:#000}.data-\[state\=checked\]\:border-token-border-default[data-state=checked]{border-color:var(--border-default)}.data-\[state\=checked\]\:bg-black[data-state=checked]{background-color:#000}.data-\[state\=checked\]\:bg-token-bg-primary[data-state=checked]{background-color:var(--bg-primary)}.data-\[state\=checked\]\:bg-token-bg-secondary[data-state=checked]{background-color:var(--bg-secondary)}.data-\[state\=checked\]\:text-token-interactive-label-secondary-default[data-state=checked]{color:var(--interactive-label-secondary-default)}.data-\[state\=checked\]\:shadow-\[0_4px_16px_0_rgba\(0\,0\,0\,0\.05\)\][data-state=checked]{--tw-shadow:0 4px 16px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=inactive\]\:hidden[data-state=inactive]{display:none}.data-\[state\=inactive\]\:cursor-pointer[data-state=inactive]{cursor:pointer}.data-\[state\=inactive\]\:text-token-text-secondary[data-state=inactive]{color:var(--text-secondary)}.data-\[state\=inactive\]\:text-token-text-tertiary[data-state=inactive]{color:var(--text-tertiary)}@media (hover:hover){.data-\[state\=inactive\]\:hover\:bg-token-bg-tertiary[data-state=inactive]:hover{background-color:var(--bg-tertiary)}.data-\[state\=inactive\]\:hover\:bg-token-interactive-bg-secondary-hover\/60[data-state=inactive]:hover{background-color:var(--interactive-bg-secondary-hover)}@supports (color:color-mix(in lab, red, red)){.data-\[state\=inactive\]\:hover\:bg-token-interactive-bg-secondary-hover\/60[data-state=inactive]:hover{background-color:color-mix(in oklab,var(--interactive-bg-secondary-hover)60%,transparent)}}.data-\[state\=inactive\]\:hover\:text-token-text-tertiary[data-state=inactive]:hover{color:var(--text-tertiary)}}.data-\[state\=open\]\:bg-token-bg-tertiary[data-state=open]{background-color:var(--bg-tertiary)}.data-\[state\=open\]\:bg-token-interactive-bg-secondary-selected[data-state=open]{background-color:var(--interactive-bg-secondary-selected)}.data-\[state\=open\]\:bg-transparent[data-state=open]{background-color:#0000}.data-\[state\=unchecked\]\:m-\[1px\][data-state=unchecked]{margin:1px}.data-\[state\=unchecked\]\:border[data-state=unchecked]{border-style:var(--tw-border-style);border-width:1px}.data-\[unbound-width\]\:min-w-\[unset\][data-unbound-width]{min-width:unset}.nth-1\:bg-\[\#FFF493\]:first-child{background-color:#fff493}.nth-2\:mb-4:nth-child(2){margin-bottom:calc(var(--spacing)*4)}.nth-2\:bg-\[\#EBEBEB\]:nth-child(2){background-color:#ebebeb}.nth-3\:bg-\[\#94E6FF\]:nth-child(3){background-color:#94e6ff}.nth-4\:bg-\[\#C8F7AB\]:nth-child(4){background-color:#c8f7ab}.nth-5\:bg-\[\#B4A6FE\]:nth-child(5){background-color:#b4a6fe}.nth-last-\[2\]\:mt-4:nth-last-child(2){margin-top:calc(var(--spacing)*4)}@supports ((-webkit-backdrop-filter:var(--tw)) or (backdrop-filter:var(--tw))){.supports-\[backdrop-filter\]\:bg-black\/70{background-color:#000000b3;background-color:lab(0% 0 0/.7)}.supports-\[backdrop-filter\]\:bg-token-bg-elevated-primary\/92{background-color:var(--bg-elevated-primary)}@supports (color:color-mix(in lab, red, red)){.supports-\[backdrop-filter\]\:bg-token-bg-elevated-primary\/92{background-color:color-mix(in oklab,var(--bg-elevated-primary)92%,transparent)}}.supports-\[backdrop-filter\]\:bg-token-bg-primary\/80{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.supports-\[backdrop-filter\]\:bg-token-bg-primary\/80{background-color:color-mix(in oklab,var(--bg-primary)80%,transparent)}}.supports-\[backdrop-filter\]\:bg-white\/80{background-color:#fffc;background-color:lab(100% -.0000298023 .0000119209/.8)}.supports-\[backdrop-filter\]\:backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}}@supports (content-visibility:auto){.supports-\[content-visibility\:auto\]\:\[contain-intrinsic-size\:auto_100lvh\]{contain-intrinsic-size:auto 100lvh}}@media (prefers-reduced-motion:no-preference){.motion-safe\:animate-\[mkt-slide-anim_linear_infinite\]{animation:linear infinite mkt-slide-anim}.motion-safe\:animate-pulse{animation:var(--animate-pulse)}.motion-safe\:animate-slideDownAndFade{animation:.2s cubic-bezier(.16,1,.3,1) slideDownAndFade}.motion-safe\:animate-spin{animation:var(--animate-spin)}.motion-safe\:transition{transition-property:color,background-color,border-color,outline-color,-webkit-text-decoration-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-\[filter\,background-color\]{transition-property:filter,background-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-\[grid-template-rows\]{transition-property:grid-template-rows;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-\[height\]{transition-property:height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-\[mask-position\]{transition-property:-webkit-mask-position,mask-position;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-\[max-height\,opacity\]{transition-property:max-height,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-\[max-height\]{transition-property:max-height;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-\[opacity\,transform\,scale\]{transition-property:opacity,transform,scale;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-\[opacity\,transform\]{transition-property:opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-\[scale\,filter\]{transition-property:scale,filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-\[shadow\,opacity\]{transition-property:shadow,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-colors{transition-property:color,background-color,border-color,outline-color,-webkit-text-decoration-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-width{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:transition-none{transition-property:none}.motion-safe\:delay-0{transition-delay:0s}.motion-safe\:delay-100{transition-delay:.1s}.motion-safe\:delay-300{transition-delay:.3s}.motion-safe\:delay-\[175ms\]{transition-delay:.175s}.motion-safe\:duration-150{--tw-duration:.15s;transition-duration:.15s}.motion-safe\:duration-200{--tw-duration:.2s;transition-duration:.2s}.motion-safe\:duration-300{--tw-duration:.3s;transition-duration:.3s}.motion-safe\:duration-500{--tw-duration:.5s;transition-duration:.5s}.motion-safe\:ease-\[cubic-bezier\(0\.22\,1\,0\.36\,1\)\]{--tw-ease:cubic-bezier(.22,1,.36,1);transition-timing-function:cubic-bezier(.22,1,.36,1)}.motion-safe\:ease-\[steps\(1\,end\)\]{--tw-ease:steps(1,end);transition-timing-function:step-end}.motion-safe\:ease-\[steps\(1\,start\)\]{--tw-ease:steps(1,start);transition-timing-function:step-start}.motion-safe\:ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.motion-safe\:ease-linear{--tw-ease:linear;transition-timing-function:linear}.motion-safe\:ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.motion-safe\:\[animation-play-state\:running\]{animation-play-state:running}.motion-safe\:\[transition\:height_0\.3s_var\(--easing-common\)\]{transition:height .3s var(--easing-common)}.motion-safe\:\[view-transition-name\:business-list-container\]{view-transition-name:business-list-container}.motion-safe\:\[view-transition-name\:map-with-entities\]{view-transition-name:map-with-entities}.motion-safe\:group-focus-within\:shadow-elevation-03:is(:where(.group):focus-within *){--tw-shadow:0px 20px 25px -5px var(--tw-shadow-color,#0000001a),0px 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}@media (hover:hover){.motion-safe\:group-hover\:shadow-elevation-03:is(:where(.group):hover *){--tw-shadow:0px 20px 25px -5px var(--tw-shadow-color,#0000001a),0px 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}}@media (hover:hover){@media (prefers-reduced-motion:no-preference){.group-hover\/app-icon\:motion-safe\:scale-\[0\.91\]:is(:where(.group\/app-icon):hover *){scale:.91}.group-hover\/app-icon\:motion-safe\:scale-\[1\.025\]:is(:where(.group\/app-icon):hover *){scale:1.025}.group-hover\/app-icon\:motion-safe\:shadow-\[0px_4px_12px_rgba\(0\,0\,0\,0\.08\)\]:is(:where(.group\/app-icon):hover *){--tw-shadow:0px 4px 12px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.group-hover\/app-icon\:motion-safe\:\[--shadow-color\:rgba\(0\,0\,0\,0\.1\)\]:is(:where(.group\/app-icon):hover *){--shadow-color:#0000001a}}}@media (prefers-reduced-motion:no-preference){@media (hover:hover){.motion-safe\:group-hover\/button\:brightness-110:is(:where(.group\/button):hover *){--tw-brightness:brightness(110%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.motion-safe\:group-hover\/button\:brightness-120:is(:where(.group\/button):hover *){--tw-brightness:brightness(120%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}@media (prefers-reduced-motion:no-preference){.motion-safe\:group-hover\/button\:motion-safe\:will-change-\[scale\,filter\]:is(:where(.group\/button):hover *){will-change:scale,filter}}}.motion-safe\:group-active\/button\:scale-98:is(:where(.group\/button):active *){--tw-scale-x:98%;--tw-scale-y:98%;--tw-scale-z:98%;scale:var(--tw-scale-x)var(--tw-scale-y)}@media (prefers-reduced-motion:no-preference){.motion-safe\:group-active\/button\:motion-safe\:will-change-\[scale\,filter\]:is(:where(.group\/button):active *){will-change:scale,filter}}.motion-safe\:before\:transition-opacity:before{content:var(--tw-content);transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\:before\:duration-300:before{content:var(--tw-content);--tw-duration:.3s;transition-duration:.3s}.motion-safe\:before\:ease-\[cubic-bezier\(0\.22\,1\,0\.36\,1\)\]:before{content:var(--tw-content);--tw-ease:cubic-bezier(.22,1,.36,1);transition-timing-function:cubic-bezier(.22,1,.36,1)}.motion-safe\:group-focus-within\:before\:opacity-35:is(:where(.group):focus-within *):before{content:var(--tw-content);opacity:.35}@media (hover:hover){.motion-safe\:group-hover\:before\:opacity-35:is(:where(.group):hover *):before{content:var(--tw-content);opacity:.35}.motion-safe\:hover\:brightness-120:hover{--tw-brightness:brightness(120%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}}.motion-safe\:active\:scale-98:active{--tw-scale-x:98%;--tw-scale-y:98%;--tw-scale-z:98%;scale:var(--tw-scale-x)var(--tw-scale-y)}}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}.motion-reduce\:transition-\[background-color\,box-shadow\]{transition-property:background-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-reduce\:transition-none{transition-property:none}.motion-reduce\:transition-none\!{transition-property:none!important}.motion-reduce\:duration-0{--tw-duration:0s;transition-duration:0s}}@media not all and (min-width:839px){.max-\[839px\]\:hidden{display:none}}@media not all and (min-width:480px){.max-xs\:hidden{display:none}.max-xs\:max-h-\[260px\]{max-height:260px}}@media not all and (min-width:400px){.max-\[400px\]\:h-auto{height:auto}}@media not all and (min-width:64rem){.max-lg\:start-1\/2:dir(ltr){left:50%}.max-lg\:start-1\/2:dir(rtl){right:50%}.max-lg\:end-auto:dir(ltr){right:auto}.max-lg\:end-auto:dir(rtl){left:auto}.max-lg\:w-0\!{width:calc(var(--spacing)*0)!important}.max-lg\:w-\[min\(280px\,calc\(100vw-24px\)\)\]{width:min(280px,100vw - 24px)}.max-lg\:-translate-x-1\/2{--tw-translate-x:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.max-lg\:mask-b-from-black{-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);--tw-mask-linear:var(--tw-mask-left),var(--tw-mask-right),var(--tw-mask-bottom),var(--tw-mask-top);--tw-mask-bottom:linear-gradient(to bottom,var(--tw-mask-bottom-from-color)var(--tw-mask-bottom-from-position),var(--tw-mask-bottom-to-color)var(--tw-mask-bottom-to-position));--tw-mask-bottom-from-color:#000;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}}@media not all and (min-width:48rem){.max-md\:pointer-events-auto{pointer-events:auto}.max-md\:sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.max-md\:absolute{position:absolute}.max-md\:fixed{position:fixed}.max-md\:relative{position:relative}.max-md\:inset-0{inset:calc(var(--spacing)*0)}.max-md\:start-0:dir(ltr){left:calc(var(--spacing)*0)}.max-md\:start-0:dir(rtl){right:calc(var(--spacing)*0)}.max-md\:end-0:dir(ltr){right:calc(var(--spacing)*0)}.max-md\:end-0:dir(rtl){left:calc(var(--spacing)*0)}.max-md\:top-\(--header-height\){top:var(--header-height)}.max-md\:top-0{top:calc(var(--spacing)*0)}.max-md\:top-13{top:calc(var(--spacing)*13)}.max-md\:z-20{z-index:20}.max-md\:z-50{z-index:50}.max-md\:mx-0{margin-inline:calc(var(--spacing)*0)}.max-md\:mx-auto{margin-left:auto;margin-right:auto}.max-md\:-ms-14:dir(ltr){margin-left:calc(var(--spacing)*-14)}.max-md\:-ms-14:dir(rtl){margin-right:calc(var(--spacing)*-14)}.max-md\:mt-0{margin-top:calc(var(--spacing)*0)}.max-md\:mt-4{margin-top:calc(var(--spacing)*4)}.max-md\:contents{display:contents}.max-md\:flex{display:flex}.max-md\:hidden{display:none}.max-md\:h-6{height:calc(var(--spacing)*6)}.max-md\:h-\[28px\]{height:28px}.max-md\:h-\[98dvh\]{height:98dvh}.max-md\:h-\[136px\]{height:136px}.max-md\:h-full{height:100%}.max-md\:max-h-\[calc\(100vh-150px\)\]{max-height:calc(100vh - 150px)}.max-md\:min-h-\[60vh\]{min-height:60vh}.max-md\:w-24{width:calc(var(--spacing)*24)}.max-md\:w-\[28px\]{width:28px}.max-md\:w-\[100dvw\]{width:100dvw}.max-md\:w-full{width:100%}.max-md\:w-full\!{width:100%!important}.max-md\:max-w-\[100dvw\]{max-width:100dvw}.max-md\:max-w-\[calc\(100\%-2\*1\.5rem\)\]{max-width:calc(100% - 3rem)}.max-md\:flex-1{flex:1}.max-md\:-translate-x-1{--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.max-md\:snap-always{scroll-snap-stop:always}.max-md\:scroll-px-4{scroll-padding-inline:calc(var(--spacing)*4)}.max-md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.max-md\:grid-rows-\[1fr_0\]{grid-template-rows:1fr 0}.max-md\:flex-col{flex-direction:column}.max-md\:flex-wrap{flex-wrap:wrap}.max-md\:\[align-items\:unset\]{align-items:unset}.max-md\:items-center{align-items:center}.max-md\:justify-between{justify-content:space-between}.max-md\:justify-center{justify-content:center}.max-md\:gap-0{gap:calc(var(--spacing)*0)}.max-md\:gap-0\.5{gap:calc(var(--spacing)*.5)}.max-md\:gap-1{gap:calc(var(--spacing)*1)}.max-md\:gap-1\.5{gap:calc(var(--spacing)*1.5)}.max-md\:gap-2{gap:calc(var(--spacing)*2)}.max-md\:overflow-clip{overflow:clip}.max-md\:overflow-x-auto{overflow-x:auto}.max-md\:overflow-y-auto{overflow-y:auto}.max-md\:rounded-xl{border-radius:var(--radius-xl)}.max-md\:border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.max-md\:bg-gray-solid-1000\/10{background-color:#0d0d0d1a;background-color:lab(3.63549% -.00000745058 .00000298023/.1)}.max-md\:bg-token-bg-primary{background-color:var(--bg-primary)}.max-md\:p-0{padding:calc(var(--spacing)*0)}.max-md\:px-\(--images-app-padding\){padding-inline:var(--images-app-padding)}.max-md\:px-3{padding-inline:calc(var(--spacing)*3)}.max-md\:px-4{padding-inline:calc(var(--spacing)*4)}.max-md\:px-6{padding-inline:calc(var(--spacing)*6)}.max-md\:py-0{padding-block:calc(var(--spacing)*0)}.max-md\:py-1\.5{padding-block:calc(var(--spacing)*1.5)}.max-md\:ps-2:dir(ltr){padding-left:calc(var(--spacing)*2)}.max-md\:ps-2:dir(rtl){padding-right:calc(var(--spacing)*2)}.max-md\:pt-0{padding-top:calc(var(--spacing)*0)}.max-md\:pt-2{padding-top:calc(var(--spacing)*2)}.max-md\:pt-4{padding-top:calc(var(--spacing)*4)}.max-md\:pt-6{padding-top:calc(var(--spacing)*6)}.max-md\:pt-17{padding-top:calc(var(--spacing)*17)}.max-md\:pb-1{padding-bottom:calc(var(--spacing)*1)}.max-md\:pb-6{padding-bottom:calc(var(--spacing)*6)}.max-md\:text-center{text-align:center}.max-md\:text-footnote-regular{font-size:var(--text-footnote-regular);line-height:var(--tw-leading,var(--text-footnote-regular--line-height));letter-spacing:var(--tw-tracking,var(--text-footnote-regular--letter-spacing));font-weight:var(--tw-font-weight,var(--text-footnote-regular--font-weight))}.max-md\:text-heading-3{font-size:var(--text-heading-3);line-height:var(--tw-leading,var(--text-heading-3--line-height));letter-spacing:var(--tw-tracking,var(--text-heading-3--letter-spacing));font-weight:var(--tw-font-weight,var(--text-heading-3--font-weight))}.max-md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.max-md\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.max-md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.max-md\:text-\[18px\]{font-size:18px}.max-md\:text-\[24px\]{font-size:24px}.max-md\:leading-\[22px\]{--tw-leading:22px;line-height:22px}.max-md\:font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.max-md\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.max-md\:tracking-\[-0\.43px\]{--tw-tracking:-.43px;letter-spacing:-.43px}.max-md\:opacity-100{opacity:1}.max-md\:sharp-edge-top-shadow{box-shadow:var(--sharp-edge-top-shadow-placeholder)}.max-md\:sharp-edge-top-shadow[data-scrolled-from-start]{box-shadow:var(--sharp-edge-top-shadow)}.max-md\:\[--composer-container-flex\:0\]{--composer-container-flex:0}.max-md\:\[--composer-container-height\:auto\]{--composer-container-height:auto}}@media not all and (min-width:40rem){.max-sm\:-mx-\(--thread-content-margin\){margin-inline:calc(var(--thread-content-margin)*-1)}.max-sm\:ms-0\.75:dir(ltr){margin-left:calc(var(--spacing)*.75)}.max-sm\:ms-0\.75:dir(rtl){margin-right:calc(var(--spacing)*.75)}.max-sm\:mt-0{margin-top:calc(var(--spacing)*0)}.max-sm\:mt-1{margin-top:calc(var(--spacing)*1)}.max-sm\:mt-6{margin-top:calc(var(--spacing)*6)}.max-sm\:mt-10{margin-top:calc(var(--spacing)*10)}.max-sm\:hidden{display:none}.max-sm\:h-6{height:calc(var(--spacing)*6)}.max-sm\:h-12{height:calc(var(--spacing)*12)}.max-sm\:h-\[80dvh\]{height:80dvh}.max-sm\:h-full{height:100%}.max-sm\:max-h-\[300px\]{max-height:300px}.max-sm\:w-6{width:calc(var(--spacing)*6)}.max-sm\:w-12{width:calc(var(--spacing)*12)}.max-sm\:w-\[100cqw\]{width:100cqw}.max-sm\:w-\[164px\]{width:164px}.max-sm\:w-full{width:100%}.max-sm\:w-screen{width:100vw}.max-sm\:max-w-none{max-width:none}.max-sm\:flex-1{flex:1}.max-sm\:flex-none{flex:none}.max-sm\:grow{flex-grow:1}.max-sm\:\!grid-cols-\[0px_1fr_0px\]{grid-template-columns:0 1fr 0!important}.max-sm\:\!grid-rows-\[minmax\(0\,1fr\)_auto_0px\]{grid-template-rows:minmax(0,1fr) auto 0!important}.max-sm\:grid-rows-\[min-content_min-content_1fr_min-content\]{grid-template-rows:min-content min-content 1fr min-content}.max-sm\:flex-col{flex-direction:column}.max-sm\:justify-center{justify-content:center}.max-sm\:justify-start{justify-content:flex-start}.max-sm\:gap-4{gap:calc(var(--spacing)*4)}.max-sm\:gap-6{gap:calc(var(--spacing)*6)}:where(.max-sm\:space-x-6>:not(:last-child)){--tw-space-x-reverse:0}:where(.max-sm\:space-x-6>:not(:last-child)):dir(ltr){margin-left:calc(calc(var(--spacing)*6)*var(--tw-space-x-reverse));margin-right:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-x-reverse)))}:where(.max-sm\:space-x-6>:not(:last-child)):dir(rtl){margin-right:calc(calc(var(--spacing)*6)*var(--tw-space-x-reverse));margin-left:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-x-reverse)))}.max-sm\:gap-y-2{row-gap:calc(var(--spacing)*2)}.max-sm\:gap-y-6{row-gap:calc(var(--spacing)*6)}.max-sm\:gap-y-8{row-gap:calc(var(--spacing)*8)}.max-sm\:overflow-hidden{overflow:hidden}.max-sm\:overflow-visible{overflow:visible}.max-sm\:overflow-y-auto{overflow-y:auto}.max-sm\:rounded-none{border-radius:0}.max-sm\:rounded-t-\[32px\]{border-top-left-radius:32px;border-top-right-radius:32px}.max-sm\:rounded-b-none{border-bottom-right-radius:0;border-bottom-left-radius:0}.max-sm\:bg-token-interactive-bg-secondary-hover{background-color:var(--interactive-bg-secondary-hover)}.max-sm\:bg-token-interactive-bg-secondary-selected{background-color:var(--interactive-bg-secondary-selected)}.max-sm\:px-0{padding-inline:calc(var(--spacing)*0)}.max-sm\:px-2{padding-inline:calc(var(--spacing)*2)}.max-sm\:px-2\!{padding-inline:calc(var(--spacing)*2)!important}.max-sm\:px-4{padding-inline:calc(var(--spacing)*4)}.max-sm\:px-5{padding-inline:calc(var(--spacing)*5)}.max-sm\:px-6{padding-inline:calc(var(--spacing)*6)}.max-sm\:py-4{padding-block:calc(var(--spacing)*4)}.max-sm\:py-5{padding-block:calc(var(--spacing)*5)}.max-sm\:pt-10{padding-top:calc(var(--spacing)*10)}.max-sm\:pb-3{padding-bottom:calc(var(--spacing)*3)}.max-sm\:pb-5{padding-bottom:calc(var(--spacing)*5)}.max-sm\:pb-25{padding-bottom:calc(var(--spacing)*25)}.max-sm\:pb-40{padding-bottom:calc(var(--spacing)*40)}.max-sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.max-sm\:shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.max-sm\:brightness-90{--tw-brightness:brightness(90%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.max-sm\:\[--sheet-radius\:var\(--sheet-radius-amount\)_var\(--sheet-radius-amount\)_0_0\]{--sheet-radius:var(--sheet-radius-amount)var(--sheet-radius-amount)0 0}}@media (min-width:420px){.min-\[420px\]\:inline-block{display:inline-block}.min-\[420px\]\:flex-row{flex-direction:row}.min-\[420px\]\:flex-wrap{flex-wrap:wrap}.min-\[420px\]\:items-center{align-items:center}.min-\[420px\]\:justify-end{justify-content:flex-end}.min-\[420px\]\:gap-2{gap:calc(var(--spacing)*2)}.min-\[420px\]\:gap-3{gap:calc(var(--spacing)*3)}}@media (min-width:480px){.xs\:ms-1\.5:dir(ltr){margin-left:calc(var(--spacing)*1.5)}.xs\:ms-1\.5:dir(rtl){margin-right:calc(var(--spacing)*1.5)}.xs\:max-h-\[206px\]{max-height:206px}.xs\:max-h-\[358px\]{max-height:358px}.xs\:max-h-\[708px\]{max-height:708px}.xs\:max-w-\[400px\]{max-width:400px}.xs\:max-w-sm\!{max-width:var(--container-sm)!important}.xs\:basis-\[calc\(\(100\%-2rem\)\/3\)\]{flex-basis:calc(33.3333% - .666667rem)}.xs\:columns-2{columns:2}.xs\:flex-col{flex-direction:column}}@media (min-width:1200px){.min-\[1200px\]\:hidden{display:none}}@media (min-width:40rem){.sm\:absolute{position:absolute}.sm\:static{position:static}.sm\:inset-x-4{inset-inline:calc(var(--spacing)*4)}.sm\:-start-\[0\%\]:dir(ltr){left:-0%}.sm\:-start-\[0\%\]:dir(rtl){right:-0%}.sm\:-start-\[5\%\]:dir(ltr){left:-5%}.sm\:-start-\[5\%\]:dir(rtl){right:-5%}.sm\:start-0:dir(ltr){left:calc(var(--spacing)*0)}.sm\:start-0:dir(rtl){right:calc(var(--spacing)*0)}.sm\:start-1\/2:dir(ltr){left:50%}.sm\:start-1\/2:dir(rtl){right:50%}.sm\:start-5:dir(ltr){left:calc(var(--spacing)*5)}.sm\:start-5:dir(rtl){right:calc(var(--spacing)*5)}.sm\:start-6:dir(ltr){left:calc(var(--spacing)*6)}.sm\:start-6:dir(rtl){right:calc(var(--spacing)*6)}.sm\:start-10:dir(ltr){left:calc(var(--spacing)*10)}.sm\:start-10:dir(rtl){right:calc(var(--spacing)*10)}.sm\:start-\[0\%\]:dir(ltr){left:0%}.sm\:start-\[0\%\]:dir(rtl){right:0%}.sm\:start-\[15\%\]:dir(ltr){left:15%}.sm\:start-\[15\%\]:dir(rtl){right:15%}.sm\:end-0:dir(ltr){right:calc(var(--spacing)*0)}.sm\:end-0:dir(rtl){left:calc(var(--spacing)*0)}.sm\:end-6:dir(ltr){right:calc(var(--spacing)*6)}.sm\:end-6:dir(rtl){left:calc(var(--spacing)*6)}.sm\:end-10:dir(ltr){right:calc(var(--spacing)*10)}.sm\:end-10:dir(rtl){left:calc(var(--spacing)*10)}.sm\:end-\[-5\%\]:dir(ltr){right:-5%}.sm\:end-\[-5\%\]:dir(rtl){left:-5%}.sm\:end-\[5\%\]:dir(ltr){right:5%}.sm\:end-\[5\%\]:dir(rtl){left:5%}.sm\:end-\[15\%\]:dir(ltr){right:15%}.sm\:end-\[15\%\]:dir(rtl){left:15%}.sm\:-top-\[15\%\]{top:-15%}.sm\:top-\(--header-height\){top:var(--header-height)}.sm\:top-30{top:calc(var(--spacing)*30)}.sm\:top-\[-15\%\]{top:-15%}.sm\:top-\[10\%\]{top:10%}.sm\:top-\[20\%\]{top:20%}.sm\:top-\[52\%\]{top:52%}.sm\:top-\[70\%\]{top:70%}.sm\:top-\[80\%\]{top:80%}.sm\:bottom-5{bottom:calc(var(--spacing)*5)}.sm\:bottom-8{bottom:calc(var(--spacing)*8)}.sm\:order-1{order:1}.sm\:order-2{order:2}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:col-span-6{grid-column:span 6/span 6}.sm\:col-span-8{grid-column:span 8/span 8}.sm\:col-span-10{grid-column:span 10/span 10}.sm\:col-start-2{grid-column-start:2}.sm\:col-start-3{grid-column-start:3}.sm\:mx-\[-32px\]{margin-left:-32px;margin-right:-32px}.sm\:mx-auto{margin-left:auto;margin-right:auto}.sm\:my-10{margin-block:calc(var(--spacing)*10)}.sm\:my-12{margin-block:calc(var(--spacing)*12)}.sm\:ms-0:dir(ltr){margin-left:calc(var(--spacing)*0)}.sm\:ms-0:dir(rtl){margin-right:calc(var(--spacing)*0)}.sm\:ms-4:dir(ltr){margin-left:calc(var(--spacing)*4)}.sm\:ms-4:dir(rtl){margin-right:calc(var(--spacing)*4)}.sm\:ms-5:dir(ltr){margin-left:calc(var(--spacing)*5)}.sm\:ms-5:dir(rtl){margin-right:calc(var(--spacing)*5)}.sm\:ms-6:dir(ltr){margin-left:calc(var(--spacing)*6)}.sm\:ms-6:dir(rtl){margin-right:calc(var(--spacing)*6)}.sm\:ms-8:dir(ltr){margin-left:calc(var(--spacing)*8)}.sm\:ms-8:dir(rtl){margin-right:calc(var(--spacing)*8)}.sm\:ms-auto:dir(ltr){margin-left:auto}.sm\:ms-auto:dir(rtl){margin-right:auto}.sm\:-me-1:dir(ltr){margin-right:calc(var(--spacing)*-1)}.sm\:-me-1:dir(rtl){margin-left:calc(var(--spacing)*-1)}.sm\:me-8:dir(ltr){margin-right:calc(var(--spacing)*8)}.sm\:me-8:dir(rtl){margin-left:calc(var(--spacing)*8)}.sm\:-mt-1\.5{margin-top:calc(var(--spacing)*-1.5)}.sm\:mt-0{margin-top:calc(var(--spacing)*0)}.sm\:mt-2{margin-top:calc(var(--spacing)*2)}.sm\:mt-3{margin-top:calc(var(--spacing)*3)}.sm\:mt-4{margin-top:calc(var(--spacing)*4)}.sm\:mt-5{margin-top:calc(var(--spacing)*5)}.sm\:mt-6{margin-top:calc(var(--spacing)*6)}.sm\:mt-8{margin-top:calc(var(--spacing)*8)}.sm\:mt-9{margin-top:calc(var(--spacing)*9)}.sm\:mt-10{margin-top:calc(var(--spacing)*10)}.sm\:mt-12{margin-top:calc(var(--spacing)*12)}.sm\:mt-16{margin-top:calc(var(--spacing)*16)}.sm\:mt-20{margin-top:calc(var(--spacing)*20)}.sm\:mt-32{margin-top:calc(var(--spacing)*32)}.sm\:mt-40{margin-top:calc(var(--spacing)*40)}.sm\:mt-\[min\(20svh\,150px\)\]{margin-top:min(20svh,150px)}.sm\:mb-0{margin-bottom:calc(var(--spacing)*0)}.sm\:mb-2{margin-bottom:calc(var(--spacing)*2)}.sm\:mb-3{margin-bottom:calc(var(--spacing)*3)}.sm\:mb-4{margin-bottom:calc(var(--spacing)*4)}.sm\:mb-6{margin-bottom:calc(var(--spacing)*6)}.sm\:line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.sm\:line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.sm\:line-clamp-6{-webkit-line-clamp:6;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.sm\:line-clamp-none{-webkit-line-clamp:unset;-webkit-box-orient:horizontal;display:block;overflow:visible}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:grid{display:grid}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:inline-block{display:inline-block}.sm\:aspect-auto{aspect-ratio:auto}.sm\:size-5{width:calc(var(--spacing)*5);height:calc(var(--spacing)*5)}.sm\:h-5{height:calc(var(--spacing)*5)}.sm\:h-6{height:calc(var(--spacing)*6)}.sm\:h-10{height:calc(var(--spacing)*10)}.sm\:h-16{height:calc(var(--spacing)*16)}.sm\:h-28{height:calc(var(--spacing)*28)}.sm\:h-30{height:calc(var(--spacing)*30)}.sm\:h-32{height:calc(var(--spacing)*32)}.sm\:h-52{height:calc(var(--spacing)*52)}.sm\:h-\[4rem\]{height:4rem}.sm\:h-\[40px\]{height:40px}.sm\:h-\[57px\]{height:57px}.sm\:h-\[78px\]{height:78px}.sm\:h-\[165px\]{height:165px}.sm\:h-\[170px\]{height:170px}.sm\:h-\[172px\]{height:172px}.sm\:h-\[262px\]{height:262px}.sm\:h-\[440px\]{height:440px}.sm\:h-\[480px\]{height:480px}.sm\:h-\[500px\]{height:500px}.sm\:h-\[600px\]\!{height:600px!important}.sm\:h-\[660px\]{height:660px}.sm\:h-\[680px\]{height:680px}.sm\:h-\[700px\]{height:700px}.sm\:h-\[calc\(100svh-4rem\)\]{height:calc(100svh - 4rem)}.sm\:h-auto{height:auto}.sm\:h-full{height:100%}.sm\:h-snc-input-height{height:var(--snc-input-height)}.sm\:max-h-80{max-height:calc(var(--spacing)*80)}.sm\:max-h-\[60vh\]{max-height:60vh}.sm\:max-h-\[70vh\]\!{max-height:70vh!important}.sm\:max-h-\[80vh\]{max-height:80vh}.sm\:max-h-\[96vh\]{max-height:96vh}.sm\:max-h-\[253px\]{max-height:253px}.sm\:max-h-\[408px\]{max-height:408px}.sm\:max-h-\[758px\]{max-height:758px}.sm\:max-h-none{max-height:none}.sm\:btn-large{min-height:calc(var(--spacing)*11);padding-inline:calc(var(--spacing)*4)}.sm\:min-h-24{min-height:calc(var(--spacing)*24)}.sm\:min-h-\[21svh\]\!{min-height:21svh!important}.sm\:min-h-\[60px\]{min-height:60px}.sm\:min-h-\[66px\]{min-height:66px}.sm\:min-h-\[112px\]{min-height:112px}.sm\:min-h-\[260px\]{min-height:260px}.sm\:min-h-\[280px\]{min-height:280px}.sm\:min-h-\[320px\]{min-height:320px}.sm\:min-h-\[386px\]{min-height:386px}.sm\:min-h-\[calc\(38svh-var\(--header-height\)\)\]{min-height:calc(38svh - var(--header-height))}.sm\:min-h-\[calc\(42svh-var\(--header-height\)\)\]{min-height:calc(42svh - var(--header-height))}.sm\:w-1\/3{width:33.3333%}.sm\:w-1\/4{width:25%}.sm\:w-1\/6{width:16.6667%}.sm\:w-2\/3{width:66.6667%}.sm\:w-5{width:calc(var(--spacing)*5)}.sm\:w-6{width:calc(var(--spacing)*6)}.sm\:w-10{width:calc(var(--spacing)*10)}.sm\:w-16{width:calc(var(--spacing)*16)}.sm\:w-28{width:calc(var(--spacing)*28)}.sm\:w-32{width:calc(var(--spacing)*32)}.sm\:w-52{width:calc(var(--spacing)*52)}.sm\:w-56{width:calc(var(--spacing)*56)}.sm\:w-60{width:calc(var(--spacing)*60)}.sm\:w-80{width:calc(var(--spacing)*80)}.sm\:w-\[20\%\]{width:20%}.sm\:w-\[22\%\]{width:22%}.sm\:w-\[30\%\]{width:30%}.sm\:w-\[36\%\]{width:36%}.sm\:w-\[40\%\]{width:40%}.sm\:w-\[78px\]{width:78px}.sm\:w-\[80\%\]{width:80%}.sm\:w-\[106px\]{width:106px}.sm\:w-\[192px\]{width:192px}.sm\:w-\[240px\]{width:240px}.sm\:w-\[250px\]{width:250px}.sm\:w-\[256px\]{width:256px}.sm\:w-\[260px\]{width:260px}.sm\:w-\[320px\]{width:320px}.sm\:w-\[368px\]{width:368px}.sm\:w-\[372px\]{width:372px}.sm\:w-\[420px\]{width:420px}.sm\:w-\[460px\]{width:460px}.sm\:w-\[486px\]{width:486px}.sm\:w-\[512px\]{width:512px}.sm\:w-\[515px\]{width:515px}.sm\:w-\[560px\]{width:560px}.sm\:w-\[600px\]{width:600px}.sm\:w-\[640px\]{width:640px}.sm\:w-\[700px\]{width:700px}.sm\:w-\[1000px\]{width:1000px}.sm\:w-\[calc\(\(100\%-0\.5rem\)\/3\)\]{width:calc(33.3333% - .166667rem)}.sm\:w-\[calc\(\(100\%-0\.5rem\)\/4\)\]{width:calc(25% - .125rem)}.sm\:w-\[calc\(100\%-2\*2rem\)\]{width:calc(100% - 4rem)}.sm\:w-\[calc\(100vw-2rem\)\]{width:calc(100vw - 2rem)}.sm\:w-\[calc\(800px-1\.25rem\)\]{width:calc(800px - 1.25rem)}.sm\:w-\[min\(720px\,calc\(100vw-32px\)\)\]{width:min(720px,100vw - 32px)}.sm\:w-\[min\(1200px\,calc\(100vw-3rem\)\)\]{width:min(1200px,100vw - 3rem)}.sm\:w-auto{width:auto}.sm\:w-fit{width:-webkit-fit-content;width:fit-content}.sm\:w-full{width:100%}.sm\:max-w-2xl{max-width:var(--container-2xl)}.sm\:max-w-100{max-width:25rem}.sm\:max-w-\[20ch\]{max-width:20ch}.sm\:max-w-\[28rem\]{max-width:28rem}.sm\:max-w-\[70\%\]{max-width:70%}.sm\:max-w-\[100\%\]{max-width:100%}.sm\:max-w-\[300px\]{max-width:300px}.sm\:max-w-\[320px\]{max-width:320px}.sm\:max-w-\[350px\]{max-width:350px}.sm\:max-w-\[380px\]{max-width:380px}.sm\:max-w-\[388px\]{max-width:388px}.sm\:max-w-\[400px\]{max-width:400px}.sm\:max-w-\[408px\]{max-width:408px}.sm\:max-w-\[416px\]{max-width:416px}.sm\:max-w-\[420px\]{max-width:420px}.sm\:max-w-\[480px\]{max-width:480px}.sm\:max-w-\[515px\]{max-width:515px}.sm\:max-w-\[520px\]{max-width:520px}.sm\:max-w-\[552px\]{max-width:552px}.sm\:max-w-\[606px\]{max-width:606px}.sm\:max-w-\[700px\]{max-width:700px}.sm\:max-w-\[820px\]{max-width:820px}.sm\:max-w-\[840px\]{max-width:840px}.sm\:max-w-\[900px\]{max-width:900px}.sm\:max-w-\[996px\]{max-width:996px}.sm\:max-w-\[calc\(100vw-10rem\)\]{max-width:calc(100vw - 10rem)}.sm\:max-w-\[calc\(100vw-380px\)\]{max-width:calc(100vw - 380px)}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:max-w-md{max-width:var(--container-md)}.sm\:max-w-none{max-width:none}.sm\:max-w-sm{max-width:var(--container-sm)}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-28{min-width:calc(var(--spacing)*28)}.sm\:min-w-\[6rem\]{min-width:6rem}.sm\:min-w-\[24rem\]{min-width:24rem}.sm\:min-w-\[120px\]{min-width:120px}.sm\:min-w-\[150px\]{min-width:150px}.sm\:min-w-\[160px\]{min-width:160px}.sm\:min-w-\[200px\]{min-width:200px}.sm\:min-w-\[220px\]{min-width:220px}.sm\:min-w-\[360px\]{min-width:360px}.sm\:flex-1{flex:1}.sm\:flex-initial{flex:0 auto}.sm\:flex-none{flex:none}.sm\:shrink-0{flex-shrink:0}.sm\:grow{flex-grow:1}.sm\:basis-0{flex-basis:calc(var(--spacing)*0)}.sm\:-translate-x-1\/2{--tw-translate-x:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.sm\:translate-x-4{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.sm\:translate-x-6{--tw-translate-x:calc(var(--spacing)*6);translate:var(--tw-translate-x)var(--tw-translate-y)}.sm\:columns-3{columns:3}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[1fr_1\.2fr\]{grid-template-columns:1fr 1.2fr}.sm\:grid-cols-\[7rem_9ch_auto_auto\]{grid-template-columns:7rem 9ch auto auto}.sm\:grid-cols-\[10px_1fr_10px\]{grid-template-columns:10px 1fr 10px}.sm\:grid-cols-\[96px_120px_auto\]{grid-template-columns:96px 120px auto}.sm\:grid-cols-\[96px_auto_auto\]{grid-template-columns:96px auto auto}.sm\:grid-cols-\[96px_auto_auto_auto\]{grid-template-columns:96px auto auto auto}.sm\:grid-cols-\[104px_minmax\(0\,1fr\)\]{grid-template-columns:104px minmax(0,1fr)}.sm\:grid-cols-\[140px_minmax\(0\,1fr\)\]{grid-template-columns:140px minmax(0,1fr)}.sm\:grid-cols-\[minmax\(0\,1fr\)_96px_auto\]{grid-template-columns:minmax(0,1fr) 96px auto}.sm\:grid-cols-\[minmax\(0\,1fr\)_100px_auto\]{grid-template-columns:minmax(0,1fr) 100px auto}.sm\:grid-cols-\[minmax\(0\,1fr\)_160px_104px_64px\]{grid-template-columns:minmax(0,1fr) 160px 104px 64px}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1fr\)_96px\]{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 96px}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,160px\)_minmax\(0\,88px\)_64px\]{grid-template-columns:minmax(0,1fr) minmax(0,160px) minmax(0,88px) 64px}.sm\:grid-cols-\[minmax\(max-content\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(max-content,1fr) auto minmax(0,1fr)}.sm\:grid-cols-\[repeat\(auto-fit\,minmax\(240px\,1fr\)\)\]{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}.sm\:grid-rows-\[minmax\(10px\,0\.8fr\)_auto_minmax\(20px\,1fr\)\]{grid-template-rows:minmax(10px,.8fr) auto minmax(20px,1fr)}.sm\:flex-col{flex-direction:column}.sm\:flex-row{flex-direction:row}.sm\:flex-row-reverse{flex-direction:row-reverse}.sm\:flex-nowrap{flex-wrap:nowrap}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-center{justify-content:center}.sm\:justify-end{justify-content:flex-end}.sm\:justify-start{justify-content:flex-start}.sm\:gap-0{gap:calc(var(--spacing)*0)}.sm\:gap-1{gap:calc(var(--spacing)*1)}.sm\:gap-2{gap:calc(var(--spacing)*2)}.sm\:gap-2\.5{gap:calc(var(--spacing)*2.5)}.sm\:gap-3{gap:calc(var(--spacing)*3)}.sm\:gap-4{gap:calc(var(--spacing)*4)}.sm\:gap-5{gap:calc(var(--spacing)*5)}.sm\:gap-6{gap:calc(var(--spacing)*6)}.sm\:gap-8{gap:calc(var(--spacing)*8)}.sm\:gap-10{gap:calc(var(--spacing)*10)}.sm\:gap-12{gap:calc(var(--spacing)*12)}.sm\:gap-16{gap:calc(var(--spacing)*16)}:where(.sm\:space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}.sm\:gap-x-2{column-gap:calc(var(--spacing)*2)}.sm\:gap-x-2\.5{column-gap:calc(var(--spacing)*2.5)}.sm\:gap-x-3{column-gap:calc(var(--spacing)*3)}.sm\:gap-x-8{column-gap:calc(var(--spacing)*8)}.sm\:gap-y-0{row-gap:calc(var(--spacing)*0)}.sm\:gap-y-2{row-gap:calc(var(--spacing)*2)}.sm\:gap-y-4{row-gap:calc(var(--spacing)*4)}.sm\:gap-y-5{row-gap:calc(var(--spacing)*5)}.sm\:gap-y-12{row-gap:calc(var(--spacing)*12)}:where(.sm\:divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.sm\:divide-token-border-light>:not(:last-child)){border-color:var(--border-light)}.sm\:self-auto{align-self:auto}.sm\:justify-self-center{justify-self:center}.sm\:justify-self-end{justify-self:flex-end}.sm\:truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.sm\:overflow-hidden{overflow:hidden}.sm\:rounded-3xl{border-radius:var(--radius-3xl)}.sm\:rounded-\[20px\]{border-radius:20px}.sm\:rounded-\[24px\]{border-radius:24px}.sm\:rounded-\[26px\]{border-radius:26px}.sm\:rounded-\[28px\]{border-radius:28px}.sm\:rounded-\[30px\]{border-radius:30px}.sm\:rounded-\[100px\]{border-radius:100px}.sm\:rounded-full{border-radius:3.40282e38px}.sm\:rounded-lg{border-radius:var(--radius-lg)}.sm\:rounded-none{border-radius:0}.sm\:rounded-ss-xl:dir(ltr){border-top-left-radius:var(--radius-xl)}.sm\:rounded-ss-xl:dir(rtl){border-top-right-radius:var(--radius-xl)}.sm\:rounded-se-xl:dir(ltr){border-top-right-radius:var(--radius-xl)}.sm\:rounded-se-xl:dir(rtl){border-top-left-radius:var(--radius-xl)}.sm\:rounded-ee-xl:dir(ltr){border-bottom-right-radius:var(--radius-xl)}.sm\:rounded-ee-xl:dir(rtl){border-bottom-left-radius:var(--radius-xl)}.sm\:rounded-es-xl:dir(ltr){border-bottom-left-radius:var(--radius-xl)}.sm\:rounded-es-xl:dir(rtl){border-bottom-right-radius:var(--radius-xl)}.sm\:rounded-t-3xl{border-top-left-radius:var(--radius-3xl);border-top-right-radius:var(--radius-3xl)}.sm\:rounded-t-\[30px\]{border-top-left-radius:30px;border-top-right-radius:30px}.sm\:rounded-b-none{border-bottom-right-radius:0;border-bottom-left-radius:0}.sm\:border{border-style:var(--tw-border-style);border-width:1px}.sm\:border-none{--tw-border-style:none;border-style:none}.sm\:border-token-border-default{border-color:var(--border-default)}.sm\:border-token-border-light{border-color:var(--border-light)}.sm\:border-token-border-light\!{border-color:var(--border-light)!important}.sm\:bg-gray-200\/50{background-color:var(--gray-200)}@supports (color:color-mix(in lab, red, red)){.sm\:bg-gray-200\/50{background-color:color-mix(in oklab,var(--gray-200)50%,transparent)}}.sm\:bg-token-bg-primary{background-color:var(--bg-primary)}.sm\:bg-token-main-surface-tertiary{background-color:var(--main-surface-tertiary)}.sm\:p-0{padding:calc(var(--spacing)*0)}.sm\:p-2{padding:calc(var(--spacing)*2)}.sm\:p-3{padding:calc(var(--spacing)*3)}.sm\:p-5{padding:calc(var(--spacing)*5)}.sm\:p-6{padding:calc(var(--spacing)*6)}.sm\:p-8{padding:calc(var(--spacing)*8)}.sm\:p-10{padding:calc(var(--spacing)*10)}.sm\:px-0{padding-inline:calc(var(--spacing)*0)}.sm\:px-2{padding-inline:calc(var(--spacing)*2)}.sm\:px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.sm\:px-3{padding-inline:calc(var(--spacing)*3)}.sm\:px-4{padding-inline:calc(var(--spacing)*4)}.sm\:px-5{padding-inline:calc(var(--spacing)*5)}.sm\:px-6{padding-inline:calc(var(--spacing)*6)}.sm\:px-8{padding-inline:calc(var(--spacing)*8)}.sm\:px-10{padding-inline:calc(var(--spacing)*10)}.sm\:px-12{padding-inline:calc(var(--spacing)*12)}.sm\:px-16{padding-inline:calc(var(--spacing)*16)}.sm\:px-24{padding-inline:calc(var(--spacing)*24)}.sm\:px-\[106px\]{padding-left:106px;padding-right:106px}.sm\:px-snc-results-padding{padding-inline:var(--snc-results-padding)}.sm\:py-1\.5{padding-block:calc(var(--spacing)*1.5)}.sm\:py-2{padding-block:calc(var(--spacing)*2)}.sm\:py-2\.5{padding-block:calc(var(--spacing)*2.5)}.sm\:py-3{padding-block:calc(var(--spacing)*3)}.sm\:py-4{padding-block:calc(var(--spacing)*4)}.sm\:py-4\.5{padding-block:calc(var(--spacing)*4.5)}.sm\:py-6{padding-block:calc(var(--spacing)*6)}.sm\:py-12{padding-block:calc(var(--spacing)*12)}.sm\:py-24{padding-block:calc(var(--spacing)*24)}.sm\:py-28{padding-block:calc(var(--spacing)*28)}.sm\:py-\[10px\]{padding-top:10px;padding-bottom:10px}.sm\:ps-2:dir(ltr){padding-left:calc(var(--spacing)*2)}.sm\:ps-2:dir(rtl){padding-right:calc(var(--spacing)*2)}.sm\:ps-4:dir(ltr){padding-left:calc(var(--spacing)*4)}.sm\:ps-4:dir(rtl){padding-right:calc(var(--spacing)*4)}.sm\:ps-5:dir(ltr){padding-left:calc(var(--spacing)*5)}.sm\:ps-5:dir(rtl){padding-right:calc(var(--spacing)*5)}.sm\:ps-6:dir(ltr){padding-left:calc(var(--spacing)*6)}.sm\:ps-6:dir(rtl){padding-right:calc(var(--spacing)*6)}.sm\:ps-16:dir(ltr){padding-left:calc(var(--spacing)*16)}.sm\:ps-16:dir(rtl){padding-right:calc(var(--spacing)*16)}.sm\:ps-\[3\.25rem\]:dir(ltr){padding-left:3.25rem}.sm\:ps-\[3\.25rem\]:dir(rtl){padding-right:3.25rem}.sm\:ps-\[14px\]:dir(ltr){padding-left:14px}.sm\:ps-\[14px\]:dir(rtl){padding-right:14px}.sm\:pe-0:dir(ltr){padding-right:calc(var(--spacing)*0)}.sm\:pe-0:dir(rtl){padding-left:calc(var(--spacing)*0)}.sm\:pe-2:dir(ltr){padding-right:calc(var(--spacing)*2)}.sm\:pe-2:dir(rtl){padding-left:calc(var(--spacing)*2)}.sm\:pe-3:dir(ltr){padding-right:calc(var(--spacing)*3)}.sm\:pe-3:dir(rtl){padding-left:calc(var(--spacing)*3)}.sm\:pe-16:dir(ltr){padding-right:calc(var(--spacing)*16)}.sm\:pe-16:dir(rtl){padding-left:calc(var(--spacing)*16)}.sm\:pe-\[16px\]:dir(ltr){padding-right:16px}.sm\:pe-\[16px\]:dir(rtl){padding-left:16px}.sm\:pt-0{padding-top:calc(var(--spacing)*0)}.sm\:pt-1{padding-top:calc(var(--spacing)*1)}.sm\:pt-3{padding-top:calc(var(--spacing)*3)}.sm\:pt-4{padding-top:calc(var(--spacing)*4)}.sm\:pt-5{padding-top:calc(var(--spacing)*5)}.sm\:pt-6{padding-top:calc(var(--spacing)*6)}.sm\:pt-7{padding-top:calc(var(--spacing)*7)}.sm\:pt-8{padding-top:calc(var(--spacing)*8)}.sm\:pt-10{padding-top:calc(var(--spacing)*10)}.sm\:pt-12{padding-top:calc(var(--spacing)*12)}.sm\:pt-14{padding-top:calc(var(--spacing)*14)}.sm\:pt-20{padding-top:calc(var(--spacing)*20)}.sm\:pt-28{padding-top:calc(var(--spacing)*28)}.sm\:pt-\[3\.5rem\]{padding-top:3.5rem}.sm\:pb-0{padding-bottom:calc(var(--spacing)*0)}.sm\:pb-0\.5{padding-bottom:calc(var(--spacing)*.5)}.sm\:pb-2{padding-bottom:calc(var(--spacing)*2)}.sm\:pb-3{padding-bottom:calc(var(--spacing)*3)}.sm\:pb-4{padding-bottom:calc(var(--spacing)*4)}.sm\:pb-5{padding-bottom:calc(var(--spacing)*5)}.sm\:pb-6{padding-bottom:calc(var(--spacing)*6)}.sm\:pb-10{padding-bottom:calc(var(--spacing)*10)}.sm\:pb-12{padding-bottom:calc(var(--spacing)*12)}.sm\:pb-15{padding-bottom:calc(var(--spacing)*15)}.sm\:pb-16{padding-bottom:calc(var(--spacing)*16)}.sm\:pb-20{padding-bottom:calc(var(--spacing)*20)}.sm\:pb-28{padding-bottom:calc(var(--spacing)*28)}.sm\:pb-\[max\(4rem\,env\(safe-area-inset-bottom\,0px\)\+1\.5rem\)\]{padding-bottom:max(4rem,env(safe-area-inset-bottom,0px) + 1.5rem)}.sm\:text-center{text-align:center}.sm\:text-end{text-align:end}.sm\:text-start{text-align:start}.sm\:text-mkt-p1{font-size:1.0625rem;line-height:var(--tw-leading,1.74994rem);letter-spacing:var(--tw-tracking,-.01em);font-weight:var(--tw-font-weight,400)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.sm\:text-base\!{font-size:var(--text-base)!important;line-height:var(--tw-leading,var(--text-base--line-height))!important}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.sm\:text-\[14px\]{font-size:14px}.sm\:text-\[22px\]{font-size:22px}.sm\:text-\[32px\]{font-size:32px}.sm\:leading-5{--tw-leading:calc(var(--spacing)*5);line-height:calc(var(--spacing)*5)}.sm\:leading-8{--tw-leading:calc(var(--spacing)*8);line-height:calc(var(--spacing)*8)}.sm\:leading-\[18px\]{--tw-leading:18px;line-height:18px}.sm\:leading-\[44px\]{--tw-leading:44px;line-height:44px}.sm\:font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.sm\:tracking-\[-0\.3px\],.sm\:tracking-\[-0\.30px\]{--tw-tracking:-.3px;letter-spacing:-.3px}.sm\:whitespace-nowrap{white-space:nowrap}.sm\:text-\[color\:var\(--text-secondary\,\#5D5D5D\)\]{color:var(--text-secondary,#5d5d5d)}.sm\:text-token-main-surface-tertiary{color:var(--main-surface-tertiary)}.sm\:text-token-text-tertiary{color:var(--text-tertiary)}.sm\:opacity-65{opacity:.65}.sm\:shadow-long{--tw-shadow:0px 8px 12px 0px var(--tw-shadow-color,var(--shadow-color-1,#00000014)),0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#0000009e));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.sm\:shadow-long:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 8px 16px 0px var(--tw-shadow-color,#00000052),inset 0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#fff3)),0px 0px 1px 0px var(--tw-shadow-color,#0000009e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.sm\:shadow-short{--tw-shadow:0px 4px 4px 0px var(--tw-shadow-color,var(--shadow-color-1,#0000000a)),0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#0000009e));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.sm\:shadow-short:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 4px 12px 0px var(--tw-shadow-color,var(--shadow-color-1,#0000001a)),inset 0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#fff3));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.sm\:shadow-\[0px_4px_48px_rgba\(0\,0\,0\,0\.08\)\]{--tw-shadow:0px 4px 48px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.sm\:shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.sm\:shadow-none{--tw-shadow:0 0 transparent;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.sm\:shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.sm\:backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}}@media not all and (prefers-reduced-motion:reduce){@media (min-width:40rem){.not-motion-reduce\:sm\:transition{transition-property:color,background-color,border-color,outline-color,-webkit-text-decoration-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.not-motion-reduce\:sm\:duration-250{--tw-duration:.25s;transition-duration:.25s}}}@media (min-width:40rem){@media (hover:hover){.sm\:group-hover\:text-token-text-primary:is(:where(.group):hover *){color:var(--text-primary)}.sm\:group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.sm\:enabled\:hover\:bg-token-bg-tertiary:enabled:hover{background-color:var(--bg-tertiary)}}}@media (min-width:48rem){.md\:pointer-events-none{pointer-events:none}.md\:absolute{position:absolute}.md\:fixed{position:fixed}.md\:relative{position:relative}.md\:relative\!{position:relative!important}.md\:sticky{position:-webkit-sticky;position:sticky}.md\:inset-\[36px\]{top:36px;bottom:36px;left:36px;right:36px}.md\:inset-x-0{inset-inline:calc(var(--spacing)*0)}.md\:-start-6:dir(ltr){left:calc(var(--spacing)*-6)}.md\:-start-6:dir(rtl){right:calc(var(--spacing)*-6)}.md\:start-0:dir(ltr){left:calc(var(--spacing)*0)}.md\:start-0:dir(rtl){right:calc(var(--spacing)*0)}.md\:start-3:dir(ltr){left:calc(var(--spacing)*3)}.md\:start-3:dir(rtl){right:calc(var(--spacing)*3)}.md\:start-4:dir(ltr){left:calc(var(--spacing)*4)}.md\:start-4:dir(rtl){right:calc(var(--spacing)*4)}.md\:start-8:dir(ltr){left:calc(var(--spacing)*8)}.md\:start-8:dir(rtl){right:calc(var(--spacing)*8)}.md\:start-\[var\(--pricing-table-padding-inline\)\]:dir(ltr){left:var(--pricing-table-padding-inline)}.md\:start-\[var\(--pricing-table-padding-inline\)\]:dir(rtl){right:var(--pricing-table-padding-inline)}.md\:start-auto:dir(ltr){left:auto}.md\:start-auto:dir(rtl){right:auto}.md\:-end-\[80px\]:dir(ltr){right:-80px}.md\:-end-\[80px\]:dir(rtl){left:-80px}.md\:end-0:dir(ltr){right:calc(var(--spacing)*0)}.md\:end-0:dir(rtl){left:calc(var(--spacing)*0)}.md\:end-2:dir(ltr){right:calc(var(--spacing)*2)}.md\:end-2:dir(rtl){left:calc(var(--spacing)*2)}.md\:end-4:dir(ltr){right:calc(var(--spacing)*4)}.md\:end-4:dir(rtl){left:calc(var(--spacing)*4)}.md\:end-6:dir(ltr){right:calc(var(--spacing)*6)}.md\:end-6:dir(rtl){left:calc(var(--spacing)*6)}.md\:end-auto:dir(ltr){right:auto}.md\:end-auto:dir(rtl){left:auto}.md\:-top-\[40px\]{top:-40px}.md\:top-1{top:calc(var(--spacing)*1)}.md\:top-2{top:calc(var(--spacing)*2)}.md\:top-4{top:calc(var(--spacing)*4)}.md\:top-6{top:calc(var(--spacing)*6)}.md\:top-10{top:calc(var(--spacing)*10)}.md\:top-16{top:calc(var(--spacing)*16)}.md\:top-20{top:calc(var(--spacing)*20)}.md\:top-\[15\%\]{top:15%}.md\:top-\[22px\]{top:22px}.md\:top-\[calc\(var\(--header-height\)\+4px\)\]{top:calc(var(--header-height) + 4px)}.md\:top-auto{top:auto}.md\:bottom-4{bottom:calc(var(--spacing)*4)}.md\:bottom-6{bottom:calc(var(--spacing)*6)}.md\:bottom-8{bottom:calc(var(--spacing)*8)}.md\:bottom-10{bottom:calc(var(--spacing)*10)}.md\:bottom-16{bottom:calc(var(--spacing)*16)}.md\:bottom-\[5\%\]{bottom:5%}.md\:z-10{z-index:10}.md\:z-20{z-index:20}.md\:order-1{order:1}.md\:order-2{order:2}.md\:order-none{order:0}.md\:col-span-1{grid-column:span 1/span 1}.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-3{grid-column:span 3/span 3}.md\:col-span-4{grid-column:span 4/span 4}.md\:col-span-5{grid-column:span 5/span 5}.md\:col-span-6{grid-column:span 6/span 6}.md\:col-span-7{grid-column:span 7/span 7}.md\:col-span-8{grid-column:span 8/span 8}.md\:col-span-9{grid-column:span 9/span 9}.md\:col-span-10{grid-column:span 10/span 10}.md\:col-span-12{grid-column:span 12/span 12}.md\:col-start-1{grid-column-start:1}.md\:col-start-2{grid-column-start:2}.md\:col-start-3{grid-column-start:3}.md\:col-start-4{grid-column-start:4}.md\:col-start-6{grid-column-start:6}.md\:col-start-7{grid-column-start:7}.md\:col-start-8{grid-column-start:8}.md\:col-start-10{grid-column-start:10}.md\:col-end-6{grid-column-end:6}.md\:col-end-10{grid-column-end:10}.md\:row-span-12{grid-row:span 12/span 12}.md\:row-start-1{grid-row-start:1}.md\:row-start-2{grid-row-start:2}.md\:m-0{margin:calc(var(--spacing)*0)}.md\:m-\[-8px\]{margin:-8px}.md\:-mx-4{margin-inline:calc(var(--spacing)*-4)}.md\:-mx-6{margin-inline:calc(var(--spacing)*-6)}.md\:-mx-\[10px\]{margin-left:-10px;margin-right:-10px}.md\:mx-0{margin-inline:calc(var(--spacing)*0)}.md\:mx-4{margin-inline:calc(var(--spacing)*4)}.md\:mx-5{margin-inline:calc(var(--spacing)*5)}.md\:mx-6{margin-inline:calc(var(--spacing)*6)}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:my-0{margin-block:calc(var(--spacing)*0)}.md\:my-4{margin-block:calc(var(--spacing)*4)}.md\:my-28\!{margin-block:calc(var(--spacing)*28)!important}.md\:ms-0:dir(ltr){margin-left:calc(var(--spacing)*0)}.md\:ms-0:dir(rtl){margin-right:calc(var(--spacing)*0)}.md\:ms-4:dir(ltr){margin-left:calc(var(--spacing)*4)}.md\:ms-4:dir(rtl){margin-right:calc(var(--spacing)*4)}.md\:ms-8:dir(ltr){margin-left:calc(var(--spacing)*8)}.md\:ms-8:dir(rtl){margin-right:calc(var(--spacing)*8)}.md\:ms-auto:dir(ltr){margin-left:auto}.md\:ms-auto:dir(rtl){margin-right:auto}.md\:-mt-4{margin-top:calc(var(--spacing)*-4)}.md\:-mt-33{margin-top:calc(var(--spacing)*-33)}.md\:-mt-\[10px\]{margin-top:-10px}.md\:mt-\(--image-page-spacing\){margin-top:var(--image-page-spacing)}.md\:mt-0{margin-top:calc(var(--spacing)*0)}.md\:mt-2{margin-top:calc(var(--spacing)*2)}.md\:mt-3{margin-top:calc(var(--spacing)*3)}.md\:mt-6{margin-top:calc(var(--spacing)*6)}.md\:mt-7{margin-top:calc(var(--spacing)*7)}.md\:mt-8{margin-top:calc(var(--spacing)*8)}.md\:mt-9{margin-top:calc(var(--spacing)*9)}.md\:mt-10{margin-top:calc(var(--spacing)*10)}.md\:mt-12{margin-top:calc(var(--spacing)*12)}.md\:mt-15{margin-top:calc(var(--spacing)*15)}.md\:mt-16{margin-top:calc(var(--spacing)*16)}.md\:mt-20{margin-top:calc(var(--spacing)*20)}.md\:mt-\[-8px\]{margin-top:-8px}.md\:mt-\[-48px\]{margin-top:-48px}.md\:mt-\[72px\]{margin-top:72px}.md\:mt-\[120px\]{margin-top:120px}.md\:mt-px{margin-top:1px}.md\:-mb-4{margin-bottom:calc(var(--spacing)*-4)}.md\:mb-0{margin-bottom:calc(var(--spacing)*0)}.md\:mb-1{margin-bottom:calc(var(--spacing)*1)}.md\:mb-2{margin-bottom:calc(var(--spacing)*2)}.md\:mb-6{margin-bottom:calc(var(--spacing)*6)}.md\:mb-8{margin-bottom:calc(var(--spacing)*8)}.md\:line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.md\:line-clamp-none{-webkit-line-clamp:unset;-webkit-box-orient:horizontal;display:block;overflow:visible}.md\:block{display:block}.md\:flex{display:flex}.md\:grid{display:grid}.md\:hidden{display:none}.md\:inline{display:inline}.md\:inline-block{display:inline-block}.md\:inline-flex{display:inline-flex}.md\:aspect-square{aspect-ratio:1}.md\:h-6{height:calc(var(--spacing)*6)}.md\:h-7{height:calc(var(--spacing)*7)}.md\:h-9{height:calc(var(--spacing)*9)}.md\:h-11{height:calc(var(--spacing)*11)}.md\:h-12{height:calc(var(--spacing)*12)}.md\:h-16{height:calc(var(--spacing)*16)}.md\:h-20{height:calc(var(--spacing)*20)}.md\:h-24{height:calc(var(--spacing)*24)}.md\:h-32{height:calc(var(--spacing)*32)}.md\:h-\[80px\]{height:80px}.md\:h-\[92px\]{height:92px}.md\:h-\[170px\]{height:170px}.md\:h-\[180px\]{height:180px}.md\:h-\[420px\]{height:420px}.md\:h-\[500px\]{height:500px}.md\:h-\[600px\]{height:600px}.md\:h-\[740px\]{height:740px}.md\:h-\[calc\(100\%-80px\)\]{height:calc(100% - 80px)}.md\:h-auto{height:auto}.md\:h-fit{height:-webkit-fit-content;height:fit-content}.md\:h-full{height:100%}.md\:h-full\!{height:100%!important}.md\:h-screen{height:100vh}.md\:max-h-\[40\.625rem\]{max-height:40.625rem}.md\:max-h-\[80vh\]{max-height:80vh}.md\:max-h-\[180px\]{max-height:180px}.md\:max-h-\[253px\]{max-height:253px}.md\:max-h-\[360px\]{max-height:360px}.md\:max-h-\[398px\]{max-height:398px}.md\:max-h-\[600px\]{max-height:600px}.md\:max-h-\[700px\]{max-height:700px}.md\:max-h-\[748px\]{max-height:748px}.md\:max-h-\[calc\(100vh-12rem\)\]{max-height:calc(100vh - 12rem)}.md\:max-h-full{max-height:100%}.md\:min-h-0{min-height:calc(var(--spacing)*0)}.md\:min-h-12{min-height:calc(var(--spacing)*12)}.md\:min-h-\[20rem\]{min-height:20rem}.md\:min-h-\[30rem\]{min-height:30rem}.md\:min-h-\[60px\]{min-height:60px}.md\:min-h-\[196px\]{min-height:196px}.md\:min-h-\[300px\]{min-height:300px}.md\:min-h-\[380px\]{min-height:380px}.md\:min-h-\[600px\]{min-height:600px}.md\:min-h-\[calc\(370px\*5\/4\)\]{min-height:462.5px}.md\:w-0{width:calc(var(--spacing)*0)}.md\:w-1\/2{width:50%}.md\:w-1\/3{width:33.3333%}.md\:w-1\/6{width:16.6667%}.md\:w-2\/3\!{width:66.6667%!important}.md\:w-3\/5{width:60%}.md\:w-3xl{width:var(--container-3xl)}.md\:w-4\/12{width:33.3333%}.md\:w-6{width:calc(var(--spacing)*6)}.md\:w-9{width:calc(var(--spacing)*9)}.md\:w-24{width:calc(var(--spacing)*24)}.md\:w-32{width:calc(var(--spacing)*32)}.md\:w-44{width:calc(var(--spacing)*44)}.md\:w-58{width:calc(var(--spacing)*58)}.md\:w-60{width:calc(var(--spacing)*60)}.md\:w-64{width:calc(var(--spacing)*64)}.md\:w-72{width:calc(var(--spacing)*72)}.md\:w-80{width:calc(var(--spacing)*80)}.md\:w-\[10rem\]{width:10rem}.md\:w-\[80px\]{width:80px}.md\:w-\[92px\]{width:92px}.md\:w-\[100px\]{width:100px}.md\:w-\[180px\]{width:180px}.md\:w-\[210px\]{width:210px}.md\:w-\[212px\]{width:212px}.md\:w-\[258px\]{width:258px}.md\:w-\[260px\]{width:260px}.md\:w-\[280px\]{width:280px}.md\:w-\[340px\]{width:340px}.md\:w-\[360px\]{width:360px}.md\:w-\[370px\]{width:370px}.md\:w-\[380px\]{width:380px}.md\:w-\[420px\]{width:420px}.md\:w-\[430px\]{width:430px}.md\:w-\[464px\]{width:464px}.md\:w-\[480px\]{width:480px}.md\:w-\[560px\]{width:560px}.md\:w-\[720px\]{width:720px}.md\:w-\[calc\(100\%-16rem\)\]{width:calc(100% - 16rem)}.md\:w-\[calc\(100\%_-_64px\)\]{width:calc(100% - 64px)}.md\:w-\[min\(100\%\,calc\(var\(--pricing-table-grid-min-width\)\+var\(--pricing-table-padding-inline\)\*2\)\)\]{width:min(100%,calc(var(--pricing-table-grid-min-width) + var(--pricing-table-padding-inline)*2))}.md\:w-auto{width:auto}.md\:w-full{width:100%}.md\:w-max{width:-webkit-max-content;width:max-content}.md\:max-w-3\/4{max-width:75%}.md\:max-w-3xl{max-width:var(--container-3xl)}.md\:max-w-96{max-width:calc(var(--spacing)*96)}.md\:max-w-117{max-width:calc(var(--spacing)*117)}.md\:max-w-\[10rem\]{max-width:10rem}.md\:max-w-\[50\%\]{max-width:50%}.md\:max-w-\[70\%\]{max-width:70%}.md\:max-w-\[180px\]{max-width:180px}.md\:max-w-\[200px\]{max-width:200px}.md\:max-w-\[210px\]{max-width:210px}.md\:max-w-\[340px\]{max-width:340px}.md\:max-w-\[350px\]{max-width:350px}.md\:max-w-\[360px\]{max-width:360px}.md\:max-w-\[380px\]{max-width:380px}.md\:max-w-\[432px\]{max-width:432px}.md\:max-w-\[440px\]{max-width:440px}.md\:max-w-\[480px\]{max-width:480px}.md\:max-w-\[515px\]{max-width:515px}.md\:max-w-\[560px\]{max-width:560px}.md\:max-w-\[600px\]{max-width:600px}.md\:max-w-\[640px\]{max-width:640px}.md\:max-w-\[672px\]{max-width:672px}.md\:max-w-\[680px\]{max-width:680px}.md\:max-w-\[900px\]{max-width:900px}.md\:max-w-\[960px\]{max-width:960px}.md\:max-w-\[calc\(100vw-480px\)\]{max-width:calc(100vw - 480px)}.md\:max-w-full{max-width:100%}.md\:max-w-none{max-width:none}.md\:max-w-screen-2xl{max-width:var(--breakpoint-2xl)}.md\:max-w-sm{max-width:var(--container-sm)}.md\:max-w-xs{max-width:var(--container-xs)}.md\:min-w-\[20rem\]{min-width:20rem}.md\:min-w-\[170px\]{min-width:170px}.md\:min-w-\[180px\]{min-width:180px}.md\:min-w-\[300px\]{min-width:300px}.md\:min-w-\[450px\]{min-width:450px}.md\:min-w-\[680px\]{min-width:680px}.md\:min-w-\[unset\]{min-width:unset}.md\:min-w-\[var\(--pricing-table-grid-min-width\)\]{min-width:var(--pricing-table-grid-min-width)}.md\:min-w-\[var\(--pricing-table-label-min-width\)\]{min-width:var(--pricing-table-label-min-width)}.md\:min-w-full{min-width:100%}.md\:min-w-md{min-width:var(--container-md)}.md\:flex-1{flex:1}.md\:flex-\[0_0_28\%\]{flex:0 0 28%}.md\:flex-\[0_0_72\%\]{flex:0 0 72%}.md\:flex-none{flex:none}.md\:shrink{flex-shrink:1}.md\:shrink-0{flex-shrink:0}.md\:grow-0{flex-grow:0}.md\:basis-0{flex-basis:calc(var(--spacing)*0)}.md\:basis-\[25vw\]{flex-basis:25vw}.md\:basis-\[75vw\]{flex-basis:75vw}.md\:basis-\[calc\(\(100\%-60px\)\/6\)\]{flex-basis:calc(16.6667% - 10px)}.md\:translate-x-0{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.md\:translate-x-\[3\%\]{--tw-translate-x:3%;translate:var(--tw-translate-x)var(--tw-translate-y)}.md\:translate-x-\[calc\(-50\%\+155px\)\]{--tw-translate-x:calc(-50% + 155px);translate:var(--tw-translate-x)var(--tw-translate-y)}.md\:translate-x-\[calc\(-50\%\+170px\)\]{--tw-translate-x:calc(-50% + 170px);translate:var(--tw-translate-x)var(--tw-translate-y)}.md\:translate-y-\[3\%\]{--tw-translate-y:3%;translate:var(--tw-translate-x)var(--tw-translate-y)}.md\:translate-y-\[calc\(-50\%\+60px\)\]{--tw-translate-y:calc(-50% + 60px);translate:var(--tw-translate-x)var(--tw-translate-y)}.md\:translate-y-\[calc\(-50\%-40px\)\]{--tw-translate-y:calc(-50% - 40px);translate:var(--tw-translate-x)var(--tw-translate-y)}.md\:scale-\[0\.94\]{scale:.94}.md\:scale-\[1\.0\]{scale:1}.md\:scroll-ps-8{scroll-padding-inline-start:calc(var(--spacing)*8)}.md\:columns-3{columns:3}.md\:auto-cols-\[minmax\(220px\,1fr\)\]{grid-auto-columns:minmax(220px,1fr)}.md\:grid-flow-col{grid-auto-flow:column}.md\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.md\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.md\:grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.md\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.md\:grid-cols-\[1fr_24rem\]{grid-template-columns:1fr 24rem}.md\:grid-cols-\[1fr_220px\]{grid-template-columns:1fr 220px}.md\:grid-cols-\[1fr_auto_1fr\]{grid-template-columns:1fr auto 1fr}.md\:grid-cols-\[3fr_2fr\]{grid-template-columns:3fr 2fr}.md\:grid-cols-\[110px_minmax\(0\,1fr\)\]{grid-template-columns:110px minmax(0,1fr)}.md\:grid-cols-\[auto\,1fr\]{grid-template-columns:auto,1fr}.md\:grid-cols-\[minmax\(0\,0\.82fr\)_minmax\(0\,1\.18fr\)\]{grid-template-columns:minmax(0,.82fr) minmax(0,1.18fr)}.md\:grid-cols-\[minmax\(0\,1\.4fr\)_minmax\(260px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.4fr) minmax(260px,.8fr)}.md\:grid-cols-\[minmax\(0\,1fr\)_9rem\]{grid-template-columns:minmax(0,1fr) 9rem}.md\:grid-cols-\[minmax\(0\,1fr\)_220px\]{grid-template-columns:minmax(0,1fr) 220px}.md\:grid-cols-\[minmax\(var\(--pricing-table-label-min-width\)\,1fr\)_repeat\(var\(--pricing-table-tier-count\)\,minmax\(var\(--pricing-table-tier-min-width\)\,1fr\)\)\]{grid-template-columns:minmax(var(--pricing-table-label-min-width),1fr)repeat(var(--pricing-table-tier-count),minmax(var(--pricing-table-tier-min-width),1fr))}.md\:grid-rows-\[20px_auto_minmax\(20px\,1fr\)\]{grid-template-rows:20px auto minmax(20px,1fr)}.md\:grid-rows-\[36px_auto_minmax\(36px\,1fr\)\]{grid-template-rows:36px auto minmax(36px,1fr)}.md\:grid-rows-\[minmax\(20px\,0\.8fr\)_auto_minmax\(20px\,1fr\)\]{grid-template-rows:minmax(20px,.8fr) auto minmax(20px,1fr)}.md\:grid-rows-\[minmax\(20px\,1fr\)_auto_20px\]{grid-template-rows:minmax(20px,1fr) auto 20px}.md\:grid-rows-\[minmax\(20px\,1fr\)_auto_minmax\(20px\,1fr\)\]{grid-template-rows:minmax(20px,1fr) auto minmax(20px,1fr)}.md\:flex-col{flex-direction:column}.md\:flex-row{flex-direction:row}.md\:flex-row-reverse{flex-direction:row-reverse}.md\:flex-nowrap{flex-wrap:nowrap}.md\:flex-wrap{flex-wrap:wrap}.md\:place-items-center{place-items:center}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:items-stretch{align-items:stretch}.md\:justify-between{justify-content:space-between}.md\:justify-between\!{justify-content:space-between!important}.md\:justify-center{justify-content:center}.md\:justify-center\!{justify-content:center!important}.md\:justify-end{justify-content:flex-end}.md\:justify-start{justify-content:flex-start}.md\:\!gap-0{gap:calc(var(--spacing)*0)!important}.md\:\!gap-4{gap:calc(var(--spacing)*4)!important}.md\:gap-0{gap:calc(var(--spacing)*0)}.md\:gap-2{gap:calc(var(--spacing)*2)}.md\:gap-3{gap:calc(var(--spacing)*3)}.md\:gap-4{gap:calc(var(--spacing)*4)}.md\:gap-5{gap:calc(var(--spacing)*5)}.md\:gap-6{gap:calc(var(--spacing)*6)}.md\:gap-8{gap:calc(var(--spacing)*8)}.md\:gap-10{gap:calc(var(--spacing)*10)}.md\:gap-12{gap:calc(var(--spacing)*12)}.md\:gap-16{gap:calc(var(--spacing)*16)}.md\:gap-36{gap:calc(var(--spacing)*36)}.min-md\:gap-3{gap:calc(var(--spacing)*3)}.md\:gap-x-2{column-gap:calc(var(--spacing)*2)}.md\:gap-x-4{column-gap:calc(var(--spacing)*4)}.md\:gap-x-6{column-gap:calc(var(--spacing)*6)}.md\:gap-y-1\.5{row-gap:calc(var(--spacing)*1.5)}.md\:gap-y-4{row-gap:calc(var(--spacing)*4)}.md\:gap-y-12{row-gap:calc(var(--spacing)*12)}.md\:self-end{align-self:flex-end}.md\:self-start{align-self:flex-start}.md\:justify-self-end{justify-self:flex-end}.md\:overflow-hidden{overflow:hidden}.md\:overflow-x-auto{overflow-x:auto}.md\:overflow-y-auto{overflow-y:auto}.md\:overflow-y-hidden{overflow-y:hidden}.md\:overscroll-x-contain{overscroll-behavior-x:contain}.md\:rounded-2xl{border-radius:var(--radius-2xl)}.md\:rounded-2xl\!{border-radius:var(--radius-2xl)!important}.md\:rounded-\[20px\]{border-radius:20px}.md\:rounded-\[22px\]{border-radius:22px}.md\:rounded-\[28px\]{border-radius:28px}.md\:rounded-lg{border-radius:var(--radius-lg)}.md\:rounded-none{border-radius:0}.md\:rounded-b-2xl{border-bottom-right-radius:var(--radius-2xl);border-bottom-left-radius:var(--radius-2xl)}.md\:border-s:dir(ltr),.md\:border-s-1:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:1px}.md\:border-s:dir(rtl),.md\:border-s-1:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-e:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-e:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:1px}.md\:border-e-0:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:0}.md\:border-e-0:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:0}.md\:border-e-1:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-e-1:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:1px}.md\:border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.md\:border-t-1{border-top-style:var(--tw-border-style);border-top-width:1px}.md\:border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.md\:border-b-1{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.md\:border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.md\:border-none{--tw-border-style:none;border-style:none}.md\:border-gray-100{border-color:var(--gray-100)}.md\:border-transparent{border-color:#0000}.md\:bg-black\/\[0\.024\]{background-color:#00000006;background-color:lab(0% 0 0/.024)}.md\:bg-gray-solid-1000{background-color:#0d0d0d}.md\:bg-token-bg-primary{background-color:var(--bg-primary)}.md\:bg-transparent{background-color:#0000}.md\:bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.md\:from-black{--tw-gradient-from:#000;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.md\:to-black\/0{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.md\:to-black\/0{--tw-gradient-to:lab(0% 0 0/0)}}.md\:\[mask-image\:linear-gradient\(to_left\,black\,transparent_80\%\)\]{-webkit-mask-image:linear-gradient(270deg,#000,#0000 80%);mask-image:linear-gradient(270deg,#000,#0000 80%)}.md\:object-\[50\%_110\%\]{object-position:50% 110%}.md\:object-center{object-position:center}.md\:p-0{padding:calc(var(--spacing)*0)}.md\:p-3{padding:calc(var(--spacing)*3)}.md\:p-4{padding:calc(var(--spacing)*4)}.md\:p-5{padding:calc(var(--spacing)*5)}.md\:p-6{padding:calc(var(--spacing)*6)}.md\:p-8{padding:calc(var(--spacing)*8)}.md\:p-12{padding:calc(var(--spacing)*12)}.md\:p-13\.5{padding:calc(var(--spacing)*13.5)}.min-md\:p-8{padding:calc(var(--spacing)*8)}.md\:px-\(--images-app-padding\){padding-inline:var(--images-app-padding)}.md\:px-0{padding-inline:calc(var(--spacing)*0)}.md\:px-2{padding-inline:calc(var(--spacing)*2)}.md\:px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.md\:px-3{padding-inline:calc(var(--spacing)*3)}.md\:px-4{padding-inline:calc(var(--spacing)*4)}.md\:px-5{padding-inline:calc(var(--spacing)*5)}.md\:px-6{padding-inline:calc(var(--spacing)*6)}.md\:px-7{padding-inline:calc(var(--spacing)*7)}.md\:px-8{padding-inline:calc(var(--spacing)*8)}.md\:px-10{padding-inline:calc(var(--spacing)*10)}.md\:px-12{padding-inline:calc(var(--spacing)*12)}.md\:px-14{padding-inline:calc(var(--spacing)*14)}.md\:px-16{padding-inline:calc(var(--spacing)*16)}.md\:px-24{padding-inline:calc(var(--spacing)*24)}.md\:px-\[60px\]{padding-left:60px;padding-right:60px}.md\:py-0{padding-block:calc(var(--spacing)*0)}.md\:py-2{padding-block:calc(var(--spacing)*2)}.md\:py-3{padding-block:calc(var(--spacing)*3)}.md\:py-4{padding-block:calc(var(--spacing)*4)}.md\:py-5{padding-block:calc(var(--spacing)*5)}.md\:py-6{padding-block:calc(var(--spacing)*6)}.md\:py-8{padding-block:calc(var(--spacing)*8)}.md\:py-10{padding-block:calc(var(--spacing)*10)}.md\:py-16{padding-block:calc(var(--spacing)*16)}.md\:py-20{padding-block:calc(var(--spacing)*20)}.md\:py-24{padding-block:calc(var(--spacing)*24)}.md\:py-32{padding-block:calc(var(--spacing)*32)}.md\:py-40{padding-block:calc(var(--spacing)*40)}.md\:py-\[22px\]{padding-top:22px;padding-bottom:22px}.md\:ps-2:dir(ltr){padding-left:calc(var(--spacing)*2)}.md\:ps-2:dir(rtl){padding-right:calc(var(--spacing)*2)}.md\:ps-3:dir(ltr){padding-left:calc(var(--spacing)*3)}.md\:ps-3:dir(rtl){padding-right:calc(var(--spacing)*3)}.md\:ps-4:dir(ltr){padding-left:calc(var(--spacing)*4)}.md\:ps-4:dir(rtl){padding-right:calc(var(--spacing)*4)}.md\:ps-5:dir(ltr){padding-left:calc(var(--spacing)*5)}.md\:ps-5:dir(rtl){padding-right:calc(var(--spacing)*5)}.md\:ps-6:dir(ltr){padding-left:calc(var(--spacing)*6)}.md\:ps-6:dir(rtl){padding-right:calc(var(--spacing)*6)}.md\:ps-8:dir(ltr){padding-left:calc(var(--spacing)*8)}.md\:ps-8:dir(rtl){padding-right:calc(var(--spacing)*8)}.md\:pe-0:dir(ltr){padding-right:calc(var(--spacing)*0)}.md\:pe-0:dir(rtl){padding-left:calc(var(--spacing)*0)}.md\:pe-2:dir(ltr){padding-right:calc(var(--spacing)*2)}.md\:pe-2:dir(rtl){padding-left:calc(var(--spacing)*2)}.md\:pe-3:dir(ltr){padding-right:calc(var(--spacing)*3)}.md\:pe-3:dir(rtl){padding-left:calc(var(--spacing)*3)}.md\:pe-4:dir(ltr){padding-right:calc(var(--spacing)*4)}.md\:pe-4:dir(rtl){padding-left:calc(var(--spacing)*4)}.md\:pe-8:dir(ltr){padding-right:calc(var(--spacing)*8)}.md\:pe-8:dir(rtl){padding-left:calc(var(--spacing)*8)}.md\:pe-12:dir(ltr){padding-right:calc(var(--spacing)*12)}.md\:pe-12:dir(rtl){padding-left:calc(var(--spacing)*12)}.md\:pe-20:dir(ltr){padding-right:calc(var(--spacing)*20)}.md\:pe-20:dir(rtl){padding-left:calc(var(--spacing)*20)}.md\:pe-56:dir(ltr){padding-right:calc(var(--spacing)*56)}.md\:pe-56:dir(rtl){padding-left:calc(var(--spacing)*56)}.md\:pt-0{padding-top:calc(var(--spacing)*0)}.md\:pt-0\!{padding-top:calc(var(--spacing)*0)!important}.md\:pt-2{padding-top:calc(var(--spacing)*2)}.md\:pt-3{padding-top:calc(var(--spacing)*3)}.md\:pt-5{padding-top:calc(var(--spacing)*5)}.md\:pt-6{padding-top:calc(var(--spacing)*6)}.md\:pt-10{padding-top:calc(var(--spacing)*10)}.md\:pt-12{padding-top:calc(var(--spacing)*12)}.md\:pt-20{padding-top:calc(var(--spacing)*20)}.md\:pt-24{padding-top:calc(var(--spacing)*24)}.md\:pt-36{padding-top:calc(var(--spacing)*36)}.md\:pt-\[3px\]{padding-top:3px}.md\:pt-\[4\.5rem\]{padding-top:4.5rem}.md\:pt-\[104px\]{padding-top:104px}.min-md\:pt-3{padding-top:calc(var(--spacing)*3)}.md\:\!pb-\[24px\]{padding-bottom:24px!important}.md\:pb-0{padding-bottom:calc(var(--spacing)*0)}.md\:pb-0\.5{padding-bottom:calc(var(--spacing)*.5)}.md\:pb-2{padding-bottom:calc(var(--spacing)*2)}.md\:pb-2\.5{padding-bottom:calc(var(--spacing)*2.5)}.md\:pb-3{padding-bottom:calc(var(--spacing)*3)}.md\:pb-5{padding-bottom:calc(var(--spacing)*5)}.md\:pb-6{padding-bottom:calc(var(--spacing)*6)}.md\:pb-12{padding-bottom:calc(var(--spacing)*12)}.md\:pb-16{padding-bottom:calc(var(--spacing)*16)}.md\:pb-20{padding-bottom:calc(var(--spacing)*20)}.md\:pb-24{padding-bottom:calc(var(--spacing)*24)}.md\:pb-40{padding-bottom:calc(var(--spacing)*40)}.md\:pb-\[60px\]{padding-bottom:60px}.md\:pl-2{padding-left:calc(var(--spacing)*2)}.md\:pl-4{padding-left:calc(var(--spacing)*4)}.md\:text-center{text-align:center}.md\:text-justify{text-align:justify}.md\:text-left{text-align:left}.md\:text-start{text-align:start}.md\:text-page-header{--tw-leading:34px;--tw-font-weight:var(--font-weight-normal);font-size:28px;line-height:34px;font-weight:var(--font-weight-normal);--tw-tracking:.38px;letter-spacing:.38px}.md\:text-body-regular{font-size:var(--text-body-regular);line-height:var(--tw-leading,var(--text-body-regular--line-height));letter-spacing:var(--tw-tracking,var(--text-body-regular--letter-spacing));font-weight:var(--tw-font-weight,var(--text-body-regular--font-weight))}.md\:text-mkt-h3{font-size:max(1.5rem,min(.56338vw + 1.36796rem,1.875rem));line-height:var(--tw-leading,clamp(1.98rem,calc(1.98rem + .495*((100vw - 23.4375rem)/66.5625)),2.475rem));letter-spacing:var(--tw-tracking,-.01em);font-weight:var(--tw-font-weight,500)}.md\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.md\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.md\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.md\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.md\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.md\:text-\[15px\]{font-size:15px}.md\:text-\[19px\]{font-size:19px}.md\:text-\[26px\]{font-size:26px}.md\:text-\[32px\]{font-size:32px}.md\:text-\[40px\]{font-size:40px}.md\:text-\[44px\]{font-size:44px}.md\:text-\[56px\]{font-size:56px}.md\:text-\[150px\]{font-size:150px}.md\:leading-8{--tw-leading:calc(var(--spacing)*8);line-height:calc(var(--spacing)*8)}.md\:leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.md\:text-pretty{text-wrap:pretty}.md\:whitespace-nowrap{white-space:nowrap}.md\:text-token-text-primary{color:var(--text-primary)}.md\:text-token-text-tertiary{color:var(--text-tertiary)}.md\:opacity-0{opacity:0}.md\:opacity-60{opacity:.6}.md\:opacity-100{opacity:1}.md\:shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.md\:\[--file-tile-width\:20rem\]{--file-tile-width:20rem}.md\:\[--gutter-min-height\:2\.25rem\]{--gutter-min-height:2.25rem}.md\:\[--offset-y-bottom\:-30px\]{--offset-y-bottom:-30px}.md\:\[-webkit-mask-image\:linear-gradient\(to_left\,black\,transparent_80\%\)\]{-webkit-mask-image:linear-gradient(270deg,#000,#0000 80%)}.md\:group-focus-within\/file-row\:pointer-events-auto:is(:where(.group\/file-row):focus-within *){pointer-events:auto}.md\:group-focus-within\/file-row\:opacity-100:is(:where(.group\/file-row):focus-within *){opacity:1}@media (hover:hover){.md\:group-hover\:translate-x-\[calc\(-50\%\+315px\)\]:is(:where(.group):hover *){--tw-translate-x:calc(-50% + 315px);translate:var(--tw-translate-x)var(--tw-translate-y)}.md\:group-hover\:translate-x-\[calc\(-50\%\+355px\)\]:is(:where(.group):hover *){--tw-translate-x:calc(-50% + 355px);translate:var(--tw-translate-x)var(--tw-translate-y)}.md\:group-hover\:translate-y-\[calc\(-50\%\+100px\)\]:is(:where(.group):hover *){--tw-translate-y:calc(-50% + 100px);translate:var(--tw-translate-x)var(--tw-translate-y)}.md\:group-hover\:translate-y-\[calc\(-50\%-75px\)\]:is(:where(.group):hover *){--tw-translate-y:calc(-50% - 75px);translate:var(--tw-translate-x)var(--tw-translate-y)}.md\:group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.md\:group-hover\/file-row\:pointer-events-auto:is(:where(.group\/file-row):hover *){pointer-events:auto}.md\:group-hover\/file-row\:opacity-100:is(:where(.group\/file-row):hover *){opacity:1}}.md\:after\:start-5:after{content:var(--tw-content)}.md\:after\:start-5:dir(ltr):after{left:calc(var(--spacing)*5)}.md\:after\:start-5:dir(rtl):after{right:calc(var(--spacing)*5)}.md\:after\:end-5:after{content:var(--tw-content)}.md\:after\:end-5:dir(ltr):after{right:calc(var(--spacing)*5)}.md\:after\:end-5:dir(rtl):after{left:calc(var(--spacing)*5)}.md\:after\:opacity-0:after{content:var(--tw-content);opacity:0}.md\:after\:opacity-100:after{content:var(--tw-content);opacity:1}.md\:first\:ms-0:first-child:dir(ltr){margin-left:calc(var(--spacing)*0)}.md\:first\:ms-0:first-child:dir(rtl){margin-right:calc(var(--spacing)*0)}.md\:first\:rounded-ss-xl:first-child:dir(ltr){border-top-left-radius:var(--radius-xl)}.md\:first\:rounded-ss-xl:first-child:dir(rtl){border-top-right-radius:var(--radius-xl)}.md\:first\:rounded-es-xl:first-child:dir(ltr){border-bottom-left-radius:var(--radius-xl)}.md\:first\:rounded-es-xl:first-child:dir(rtl){border-bottom-right-radius:var(--radius-xl)}.md\:last\:me-0:last-child:dir(ltr){margin-right:calc(var(--spacing)*0)}.md\:last\:me-0:last-child:dir(rtl){margin-left:calc(var(--spacing)*0)}.md\:last\:mb-6:last-child{margin-bottom:calc(var(--spacing)*6)}.md\:last\:rounded-se-xl:last-child:dir(ltr){border-top-right-radius:var(--radius-xl)}.md\:last\:rounded-se-xl:last-child:dir(rtl){border-top-left-radius:var(--radius-xl)}.md\:last\:rounded-ee-xl:last-child:dir(ltr){border-bottom-right-radius:var(--radius-xl)}.md\:last\:rounded-ee-xl:last-child:dir(rtl){border-bottom-left-radius:var(--radius-xl)}.md\:last\:border-e:last-child:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:1px}.md\:last\:border-e:last-child:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:1px}.md\:focus-within\:opacity-100:focus-within{opacity:1}@media (hover:hover){.md\:hover\:bg-gray-50:hover{background-color:var(--gray-50)}.md\:hover\:bg-token-surface-hover:hover{background-color:var(--surface-hover)}.md\:hover\:opacity-120:hover{opacity:1.2}}.md\:focus-visible\:bg-token-surface-hover:focus-visible{background-color:var(--surface-hover)}}@media (min-width:64rem){.lg\:absolute{position:absolute}.lg\:fixed{position:fixed}.lg\:static{position:static}.lg\:inset-auto{top:auto;bottom:auto;left:auto;right:auto}.lg\:inset-x-0{inset-inline:calc(var(--spacing)*0)}.lg\:-start-8:dir(ltr){left:calc(var(--spacing)*-8)}.lg\:-start-8:dir(rtl){right:calc(var(--spacing)*-8)}.lg\:-start-\[17\%\]:dir(ltr){left:-17%}.lg\:-start-\[17\%\]:dir(rtl){right:-17%}.lg\:start-1\/2:dir(ltr){left:50%}.lg\:start-1\/2:dir(rtl){right:50%}.lg\:start-3:dir(ltr){left:calc(var(--spacing)*3)}.lg\:start-3:dir(rtl){right:calc(var(--spacing)*3)}.lg\:start-4:dir(ltr){left:calc(var(--spacing)*4)}.lg\:start-4:dir(rtl){right:calc(var(--spacing)*4)}.lg\:start-8:dir(ltr){left:calc(var(--spacing)*8)}.lg\:start-8:dir(rtl){right:calc(var(--spacing)*8)}.lg\:start-\[0\%\]:dir(ltr){left:0%}.lg\:start-\[0\%\]:dir(rtl){right:0%}.lg\:start-\[20\%\]:dir(ltr){left:20%}.lg\:start-\[20\%\]:dir(rtl){right:20%}.lg\:start-\[calc\(100\%\+8px\)\]:dir(ltr){left:calc(100% + 8px)}.lg\:start-\[calc\(100\%\+8px\)\]:dir(rtl){right:calc(100% + 8px)}.lg\:-end-\[20\%\]:dir(ltr){right:-20%}.lg\:-end-\[20\%\]:dir(rtl){left:-20%}.lg\:end-4:dir(ltr){right:calc(var(--spacing)*4)}.lg\:end-4:dir(rtl){left:calc(var(--spacing)*4)}.lg\:end-\[-2\%\]:dir(ltr){right:-2%}.lg\:end-\[-2\%\]:dir(rtl){left:-2%}.lg\:end-\[10\%\]:dir(ltr){right:10%}.lg\:end-\[10\%\]:dir(rtl){left:10%}.lg\:-top-24{top:calc(var(--spacing)*-24)}.lg\:top-0{top:calc(var(--spacing)*0)}.lg\:top-8{top:calc(var(--spacing)*8)}.lg\:top-20{top:calc(var(--spacing)*20)}.lg\:top-\[-15\%\]{top:-15%}.lg\:top-\[10\%\]{top:10%}.lg\:top-\[15\%\]{top:15%}.lg\:top-\[20\%\]{top:20%}.lg\:top-\[52\%\]{top:52%}.lg\:top-\[70\%\]{top:70%}.lg\:bottom-0{bottom:calc(var(--spacing)*0)}.lg\:bottom-5{bottom:calc(var(--spacing)*5)}.lg\:z-10{z-index:10}.lg\:order-1{order:1}.lg\:order-2{order:2}.lg\:order-3{order:3}.lg\:order-last{order:9999}.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-4{grid-column:span 4/span 4}.lg\:col-span-6{grid-column:span 6/span 6}.lg\:col-span-8{grid-column:span 8/span 8}.lg\:col-span-10{grid-column:span 10/span 10}.lg\:col-start-2{grid-column-start:2}.lg\:col-start-3{grid-column-start:3}.lg\:col-start-4{grid-column-start:4}.lg\:col-start-5{grid-column-start:5}.lg\:row-span-1{grid-row:span 1/span 1}.lg\:m-0{margin:calc(var(--spacing)*0)}.lg\:-mx-6{margin-inline:calc(var(--spacing)*-6)}.lg\:-mx-8{margin-inline:calc(var(--spacing)*-8)}.lg\:mx-10{margin-inline:calc(var(--spacing)*10)}.lg\:mx-auto{margin-left:auto;margin-right:auto}.lg\:my-24{margin-block:calc(var(--spacing)*24)}.lg\:-ms-4:dir(ltr){margin-left:calc(var(--spacing)*-4)}.lg\:-ms-4:dir(rtl){margin-right:calc(var(--spacing)*-4)}.lg\:-ms-8:dir(ltr){margin-left:calc(var(--spacing)*-8)}.lg\:-ms-8:dir(rtl){margin-right:calc(var(--spacing)*-8)}.lg\:me-4:dir(ltr){margin-right:calc(var(--spacing)*4)}.lg\:me-4:dir(rtl){margin-left:calc(var(--spacing)*4)}.lg\:mt-0{margin-top:calc(var(--spacing)*0)}.lg\:mt-2{margin-top:calc(var(--spacing)*2)}.lg\:mt-10{margin-top:calc(var(--spacing)*10)}.lg\:mb-8{margin-bottom:calc(var(--spacing)*8)}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:inline-flex{display:inline-flex}.lg\:h-1\.5{height:calc(var(--spacing)*1.5)}.lg\:h-5{height:calc(var(--spacing)*5)}.lg\:h-8{height:calc(var(--spacing)*8)}.lg\:h-12{height:calc(var(--spacing)*12)}.lg\:h-36{height:calc(var(--spacing)*36)}.lg\:h-80{height:calc(var(--spacing)*80)}.lg\:h-\[520px\]{height:520px}.lg\:h-\[720px\]{height:720px}.lg\:h-full{height:100%}.lg\:max-h-\[70vh\]{max-height:70vh}.lg\:max-h-\[305px\]{max-height:305px}.lg\:max-h-\[458px\]{max-height:458px}.lg\:max-h-\[848px\]{max-height:848px}.lg\:max-h-\[calc\(100vh-6rem\)\]{max-height:calc(100vh - 6rem)}.lg\:min-h-0{min-height:calc(var(--spacing)*0)}.lg\:min-h-12{min-height:calc(var(--spacing)*12)}.lg\:min-h-\[320px\]{min-height:320px}.lg\:w-1\.5{width:calc(var(--spacing)*1.5)}.lg\:w-1\/3{width:33.3333%}.lg\:w-1\/4{width:25%}.lg\:w-1\/5{width:20%}.lg\:w-2\/3{width:66.6667%}.lg\:w-5{width:calc(var(--spacing)*5)}.lg\:w-6{width:calc(var(--spacing)*6)}.lg\:w-8{width:calc(var(--spacing)*8)}.lg\:w-12{width:calc(var(--spacing)*12)}.lg\:w-44{width:calc(var(--spacing)*44)}.lg\:w-62{width:calc(var(--spacing)*62)}.lg\:w-64{width:calc(var(--spacing)*64)}.lg\:w-96{width:calc(var(--spacing)*96)}.lg\:w-100{width:25rem}.lg\:w-\[27\%\]{width:27%}.lg\:w-\[30\%\]{width:30%}.lg\:w-\[160px\]{width:160px}.lg\:w-\[320px\]{width:320px}.lg\:w-\[430px\]{width:430px}.lg\:w-\[440px\]{width:440px}.lg\:w-\[480px\]{width:480px}.lg\:w-\[680px\]{width:680px}.lg\:w-\[var\(--codex-security-left-pane-width\)\]{width:var(--codex-security-left-pane-width)}.lg\:w-auto{width:auto}.lg\:max-w-1\/2{max-width:50%}.lg\:max-w-2xl{max-width:var(--container-2xl)}.lg\:max-w-3xl{max-width:var(--container-3xl)}.lg\:max-w-52{max-width:calc(var(--spacing)*52)}.lg\:max-w-64{max-width:calc(var(--spacing)*64)}.lg\:max-w-72{max-width:calc(var(--spacing)*72)}.lg\:max-w-96{max-width:calc(var(--spacing)*96)}.lg\:max-w-100{max-width:25rem}.lg\:max-w-200{max-width:calc(var(--spacing)*200)}.lg\:max-w-\[40rem\]{max-width:40rem}.lg\:max-w-\[80\%\]{max-width:80%}.lg\:max-w-\[90\%\]{max-width:90%}.lg\:max-w-\[200px\]{max-width:200px}.lg\:max-w-\[260px\]{max-width:260px}.lg\:max-w-\[300px\]{max-width:300px}.lg\:max-w-\[796px\]{max-width:796px}.lg\:max-w-\[800px\]{max-width:800px}.lg\:max-w-\[1024px\]{max-width:1024px}.lg\:min-w-12{min-width:calc(var(--spacing)*12)}.lg\:min-w-20{min-width:calc(var(--spacing)*20)}.lg\:min-w-100{min-width:25rem}.lg\:min-w-\[420px\]{min-width:420px}.lg\:flex-1{flex:1}.lg\:shrink-0{flex-shrink:0}.lg\:flex-grow-0{flex-grow:0}.lg\:scroll-m-6{scroll-margin:calc(var(--spacing)*6)}.lg\:columns-3{columns:3}.lg\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-\[1\.2fr_2fr\]{grid-template-columns:1.2fr 2fr}.lg\:grid-cols-\[1fr_auto_1fr\]{grid-template-columns:1fr auto 1fr}.lg\:grid-cols-\[60\%_40\%\]{grid-template-columns:60% 40%}.lg\:grid-cols-\[220px_minmax\(0\,1fr\)\]{grid-template-columns:220px minmax(0,1fr)}.lg\:grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.lg\:grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.lg\:grid-rows-2{grid-template-rows:repeat(2,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:flex-nowrap{flex-wrap:nowrap}.lg\:flex-wrap{flex-wrap:wrap}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:justify-center{justify-content:center}.lg\:justify-center\!{justify-content:center!important}.lg\:justify-end{justify-content:flex-end}.lg\:gap-0{gap:calc(var(--spacing)*0)}.lg\:gap-4{gap:calc(var(--spacing)*4)}.lg\:gap-6{gap:calc(var(--spacing)*6)}.lg\:gap-8{gap:calc(var(--spacing)*8)}.lg\:gap-12{gap:calc(var(--spacing)*12)}.lg\:gap-14{gap:calc(var(--spacing)*14)}.lg\:gap-18{gap:calc(var(--spacing)*18)}.lg\:gap-20{gap:calc(var(--spacing)*20)}.lg\:gap-\[5px\]{gap:5px}:where(.lg\:space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*.5)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*.5)*calc(1 - var(--tw-space-y-reverse)))}.lg\:gap-x-3{column-gap:calc(var(--spacing)*3)}.lg\:gap-x-6{column-gap:calc(var(--spacing)*6)}.lg\:gap-y-2{row-gap:calc(var(--spacing)*2)}.lg\:gap-y-2\.5{row-gap:calc(var(--spacing)*2.5)}.lg\:gap-y-16{row-gap:calc(var(--spacing)*16)}.lg\:overflow-auto{overflow:auto}.lg\:overflow-hidden{overflow:hidden}.lg\:rounded-4xl{border-radius:var(--radius-4xl)}.lg\:rounded-xl{border-radius:var(--radius-xl)}.lg\:border-s:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:1px}.lg\:border-s:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:1px}.lg\:border-e:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:1px}.lg\:border-e:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:1px}.lg\:border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.lg\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.lg\:bg-token-bg-primary\/85{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.lg\:bg-token-bg-primary\/85{background-color:color-mix(in oklab,var(--bg-primary)85%,transparent)}}.lg\:bg-transparent{background-color:#0000}.lg\:mask-r-from-black{-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);--tw-mask-linear:var(--tw-mask-left),var(--tw-mask-right),var(--tw-mask-bottom),var(--tw-mask-top);--tw-mask-right:linear-gradient(to right,var(--tw-mask-right-from-color)var(--tw-mask-right-from-position),var(--tw-mask-right-to-color)var(--tw-mask-right-to-position));--tw-mask-right-from-color:#000;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}.lg\:p-3{padding:calc(var(--spacing)*3)}.lg\:p-4{padding:calc(var(--spacing)*4)}.lg\:p-8{padding:calc(var(--spacing)*8)}.lg\:px-0{padding-inline:calc(var(--spacing)*0)}.lg\:px-2{padding-inline:calc(var(--spacing)*2)}.lg\:px-3{padding-inline:calc(var(--spacing)*3)}.lg\:px-4{padding-inline:calc(var(--spacing)*4)}.lg\:px-6{padding-inline:calc(var(--spacing)*6)}.lg\:px-7{padding-inline:calc(var(--spacing)*7)}.lg\:px-8{padding-inline:calc(var(--spacing)*8)}.lg\:px-10{padding-inline:calc(var(--spacing)*10)}.lg\:px-14{padding-inline:calc(var(--spacing)*14)}.lg\:px-16{padding-inline:calc(var(--spacing)*16)}.lg\:px-24{padding-inline:calc(var(--spacing)*24)}.lg\:px-\[14px\]{padding-left:14px;padding-right:14px}.lg\:py-0{padding-block:calc(var(--spacing)*0)}.lg\:py-2{padding-block:calc(var(--spacing)*2)}.lg\:py-3{padding-block:calc(var(--spacing)*3)}.lg\:py-6{padding-block:calc(var(--spacing)*6)}.lg\:ps-0:dir(ltr){padding-left:calc(var(--spacing)*0)}.lg\:ps-0:dir(rtl){padding-right:calc(var(--spacing)*0)}.lg\:ps-4:dir(ltr){padding-left:calc(var(--spacing)*4)}.lg\:ps-4:dir(rtl){padding-right:calc(var(--spacing)*4)}.lg\:ps-6:dir(ltr){padding-left:calc(var(--spacing)*6)}.lg\:ps-6:dir(rtl){padding-right:calc(var(--spacing)*6)}.lg\:ps-8:dir(ltr){padding-left:calc(var(--spacing)*8)}.lg\:ps-8:dir(rtl){padding-right:calc(var(--spacing)*8)}.lg\:ps-10:dir(ltr){padding-left:calc(var(--spacing)*10)}.lg\:ps-10:dir(rtl){padding-right:calc(var(--spacing)*10)}.lg\:pe-0:dir(ltr){padding-right:calc(var(--spacing)*0)}.lg\:pe-0:dir(rtl){padding-left:calc(var(--spacing)*0)}.lg\:pe-4:dir(ltr){padding-right:calc(var(--spacing)*4)}.lg\:pe-4:dir(rtl){padding-left:calc(var(--spacing)*4)}.lg\:pe-5:dir(ltr){padding-right:calc(var(--spacing)*5)}.lg\:pe-5:dir(rtl){padding-left:calc(var(--spacing)*5)}.lg\:pe-6:dir(ltr){padding-right:calc(var(--spacing)*6)}.lg\:pe-6:dir(rtl){padding-left:calc(var(--spacing)*6)}.lg\:pe-16:dir(ltr){padding-right:calc(var(--spacing)*16)}.lg\:pe-16:dir(rtl){padding-left:calc(var(--spacing)*16)}.lg\:pt-0{padding-top:calc(var(--spacing)*0)}.lg\:pt-8{padding-top:calc(var(--spacing)*8)}.lg\:pt-16{padding-top:calc(var(--spacing)*16)}.lg\:pb-0{padding-bottom:calc(var(--spacing)*0)}.lg\:pb-1{padding-bottom:calc(var(--spacing)*1)}.lg\:pb-2{padding-bottom:calc(var(--spacing)*2)}.lg\:pb-12{padding-bottom:calc(var(--spacing)*12)}.lg\:pb-20{padding-bottom:calc(var(--spacing)*20)}.lg\:pb-\[0\.25em\]{padding-bottom:.25em}.lg\:text-start{text-align:start}.lg\:text-mkt-h4{font-size:max(1.25rem,min(.187793vw + 1.20599rem,1.375rem));line-height:var(--tw-leading,clamp(1.5rem,calc(1.5rem + .2325*((100vw - 23.4375rem)/66.5625)),1.7325rem));letter-spacing:var(--tw-tracking,-.01em);font-weight:var(--tw-font-weight,500)}.lg\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.lg\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.lg\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.lg\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.lg\:text-\[0\.75rem\]{font-size:.75rem}.lg\:text-\[12px\]{font-size:12px}.lg\:text-\[13px\]{font-size:13px}.lg\:leading-\[16px\]{--tw-leading:16px;line-height:16px}.lg\:shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.lg\:backdrop-blur-none{--tw-backdrop-blur: ;-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}@media (min-width:48rem){.lg\:md\:shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}}@media (min-width:80rem){.xl\:order-1{order:1}.xl\:order-2{order:2}.xl\:col-span-1{grid-column:span 1/span 1}.xl\:col-span-2{grid-column:span 2/span 2}.xl\:col-span-5{grid-column:span 5/span 5}.xl\:col-span-6{grid-column:span 6/span 6}.xl\:col-span-8{grid-column:span 8/span 8}.xl\:col-span-10{grid-column:span 10/span 10}.xl\:col-start-2{grid-column-start:2}.xl\:col-start-3{grid-column-start:3}.xl\:col-start-4{grid-column-start:4}.xl\:-mx-10{margin-inline:calc(var(--spacing)*-10)}.xl\:mx-auto{margin-left:auto;margin-right:auto}.xl\:block{display:block}.xl\:flex{display:flex}.xl\:hidden{display:none}.xl\:h-\[60vh\]{height:60vh}.xl\:h-\[calc\(100vh-10rem\)\]{height:calc(100vh - 10rem)}.xl\:h-full{height:100%}.xl\:min-h-0{min-height:calc(var(--spacing)*0)}.xl\:min-h-44{min-height:calc(var(--spacing)*44)}.xl\:w-10{width:calc(var(--spacing)*10)}.xl\:w-full{width:100%}.xl\:max-w-3xl{max-width:var(--container-3xl)}.xl\:max-w-4xl{max-width:var(--container-4xl)}.xl\:max-w-64{max-width:calc(var(--spacing)*64)}.xl\:max-w-\[40rem\]{max-width:40rem}.xl\:max-w-\[46rem\]{max-width:46rem}.xl\:max-w-\[48rem\]{max-width:48rem}.xl\:max-w-\[1120px\]{max-width:1120px}.xl\:max-w-screen-2xl{max-width:var(--breakpoint-2xl)}.xl\:max-w-xs{max-width:var(--container-xs)}.xl\:flex-1{flex:1}.xl\:scroll-m-10{scroll-margin:calc(var(--spacing)*10)}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:grid-cols-\[20rem_minmax\(360px\,1fr\)\]{grid-template-columns:20rem minmax(360px,1fr)}.xl\:grid-cols-\[26rem_minmax\(0\,1fr\)\]{grid-template-columns:26rem minmax(0,1fr)}.xl\:grid-cols-\[240px_minmax\(0\,1fr\)\]{grid-template-columns:240px minmax(0,1fr)}.xl\:grid-cols-\[320px_minmax\(0\,1fr\)\]{grid-template-columns:320px minmax(0,1fr)}.xl\:grid-cols-\[360px_minmax\(0\,1fr\)\]{grid-template-columns:360px minmax(0,1fr)}.xl\:grid-cols-\[380px_minmax\(0\,1fr\)\]{grid-template-columns:380px minmax(0,1fr)}.xl\:grid-cols-\[420px_minmax\(0\,1fr\)\]{grid-template-columns:420px minmax(0,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(0\,0\.9fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr)}.xl\:grid-cols-\[minmax\(0\,1fr\)_40px\]{grid-template-columns:minmax(0,1fr) 40px}.xl\:grid-cols-\[minmax\(0\,1fr\)_var\(--usage-analytics-insight-sidebar-width\)\]{grid-template-columns:minmax(0,1fr)var(--usage-analytics-insight-sidebar-width)}.xl\:grid-cols-\[minmax\(0\,420px\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,420px) minmax(0,1fr)}.xl\:grid-rows-1{grid-template-rows:repeat(1,minmax(0,1fr))}.xl\:flex-col{flex-direction:column}.xl\:flex-row{flex-direction:row}.xl\:items-end{align-items:flex-end}.xl\:items-stretch{align-items:stretch}.xl\:justify-between{justify-content:space-between}.xl\:gap-2{gap:calc(var(--spacing)*2)}.xl\:gap-x-2\.5{column-gap:calc(var(--spacing)*2.5)}.xl\:gap-y-2\.5{row-gap:calc(var(--spacing)*2.5)}.xl\:self-stretch{align-self:stretch}.xl\:overflow-auto{overflow:auto}.xl\:overflow-x-hidden{overflow-x:hidden}.xl\:overflow-y-auto{overflow-y:auto}.xl\:border-s:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:1px}.xl\:border-s:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:1px}.xl\:border-e:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:1px}.xl\:border-e:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:1px}.xl\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.xl\:border-token-border-light{border-color:var(--border-light)}.xl\:px-2{padding-inline:calc(var(--spacing)*2)}.xl\:px-3{padding-inline:calc(var(--spacing)*3)}.xl\:px-4{padding-inline:calc(var(--spacing)*4)}.xl\:px-7{padding-inline:calc(var(--spacing)*7)}.xl\:px-8{padding-inline:calc(var(--spacing)*8)}.xl\:px-10{padding-inline:calc(var(--spacing)*10)}.xl\:px-24{padding-inline:calc(var(--spacing)*24)}.xl\:py-6{padding-block:calc(var(--spacing)*6)}.xl\:ps-24:dir(ltr){padding-left:calc(var(--spacing)*24)}.xl\:ps-24:dir(rtl){padding-right:calc(var(--spacing)*24)}.xl\:pe-2:dir(ltr){padding-right:calc(var(--spacing)*2)}.xl\:pe-2:dir(rtl){padding-left:calc(var(--spacing)*2)}.xl\:pt-6{padding-top:calc(var(--spacing)*6)}.xl\:pt-10{padding-top:calc(var(--spacing)*10)}.xl\:pb-4{padding-bottom:calc(var(--spacing)*4)}.xl\:pb-\[0\.375em\]{padding-bottom:.375em}.xl\:text-mkt-h3{font-size:max(1.5rem,min(.56338vw + 1.36796rem,1.875rem));line-height:var(--tw-leading,clamp(1.98rem,calc(1.98rem + .495*((100vw - 23.4375rem)/66.5625)),2.475rem));letter-spacing:var(--tw-tracking,-.01em);font-weight:var(--tw-font-weight,500)}.xl\:text-mkt-p2{font-size:.875rem;line-height:var(--tw-leading,1.435rem);letter-spacing:var(--tw-tracking,-.01em);font-weight:var(--tw-font-weight,400)}.xl\:text-\[14px\]{font-size:14px}.xl\:text-pretty{text-wrap:pretty}}@media (min-width:96rem){.\32 xl\:mb-8{margin-bottom:calc(var(--spacing)*8)}.\32 xl\:max-w-\[46rem\]{max-width:46rem}.\32 xl\:max-w-\[52rem\]{max-width:52rem}.\32 xl\:\[scroll-padding-inline-start\:calc\(\(100\%_-_90rem\)_\/_2_\+_2rem\)\]{scroll-padding-inline-start:calc(50% - 43rem)}.\32 xl\:scroll-ps-\[calc\(\(100\%_-_96rem\)_\/_2_\+_32px\)\]{scroll-padding-inline-start:calc(50% - 48rem + 32px)}.\32 xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.\32 xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.\32 xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.\32 xl\:grid-cols-\[360px_minmax\(0\,1fr\)\]{grid-template-columns:360px minmax(0,1fr)}.\32 xl\:px-\[calc\(\(100\%_-_90rem\)_\/_2_\+_2rem\)\]{padding-left:calc(50% - 43rem);padding-right:calc(50% - 43rem)}.\32 xl\:ps-\[calc\(\(100\%_-_96rem\)_\/_2_\+_32px\)\]:dir(ltr){padding-left:calc(50% - 48rem + 32px)}.\32 xl\:ps-\[calc\(\(100\%_-_96rem\)_\/_2_\+_32px\)\]:dir(rtl){padding-right:calc(50% - 48rem + 32px)}.\32 xl\:pt-8{padding-top:calc(var(--spacing)*8)}.\32 xl\:pt-12{padding-top:calc(var(--spacing)*12)}.\32 xl\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}}@container not (width>=400px){.\@max-\[400px\]\:max-w-full{max-width:100%}}@container not (width>=48rem){.\@max-3xl\:-top-2{top:calc(var(--spacing)*-2)}}@container main not (width>=43rem){.\@max-\[43rem\]\/main\:-mx-\(--thread-content-margin\){margin-inline:calc(var(--thread-content-margin)*-1)}.\@max-\[43rem\]\/main\:scroll-m-\(--thread-content-margin\){scroll-margin:var(--thread-content-margin)}.\@max-\[43rem\]\/main\:px-\(--thread-content-margin\){padding-inline:var(--thread-content-margin)}}@container main not (width>=42rem){.\@max-2xl\/main\:mask-b-from-black{-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);--tw-mask-linear:var(--tw-mask-left),var(--tw-mask-right),var(--tw-mask-bottom),var(--tw-mask-top);--tw-mask-bottom:linear-gradient(to bottom,var(--tw-mask-bottom-from-color)var(--tw-mask-bottom-from-position),var(--tw-mask-bottom-to-color)var(--tw-mask-bottom-to-position));--tw-mask-bottom-from-color:#000;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}}@container not (width>=32rem){.\@max-lg\:auto-cols-\[70\%\]{grid-auto-columns:70%}}@container not (width>=28rem){.\@max-md\:end-1:dir(ltr){right:calc(var(--spacing)*1)}.\@max-md\:end-1:dir(rtl){left:calc(var(--spacing)*1)}.\@max-md\:top-3{top:calc(var(--spacing)*3)}.\@max-md\:-m-1{margin:calc(var(--spacing)*-1)}}@container (width>=0){.\@\[0px\]\:hidden{display:none}}@container (width>=150px){.\@\[150px\]\:block{display:block}}@container composer (width>=300px){.\@\[300px\]\/composer\:flex{display:flex}}@container composer (width>=310px){.\@\[310px\]\/composer\:flex{display:flex}}@container composer (width>=400px){.\@\[400px\]\/composer\:flex{display:flex}}@container composer (width>=800px){.\@\[800px\]\/composer\:flex{display:flex}}@container col (width>=24rem){.\@sm\/col\:col-span-1{grid-column:span 1/span 1}}@container preview-pane (width>=24rem){.\@sm\/preview-pane\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.\@sm\/preview-pane\:items-center{align-items:center}}@container (width>=28rem){.\@md\:absolute{position:absolute}.\@md\:start-0:dir(ltr){left:calc(var(--spacing)*0)}.\@md\:start-0:dir(rtl){right:calc(var(--spacing)*0)}.\@md\:top-1{top:calc(var(--spacing)*1)}}@container col (width>=28rem){.\@md\/col\:col-span-8{grid-column:span 8/span 8}}@container (width>=28rem){.\@md\:col-span-1{grid-column:span 1/span 1}}@container col (width>=28rem){.\@md\/col\:col-start-3{grid-column-start:3}}@container (width>=28rem){.\@md\:mb-0{margin-bottom:calc(var(--spacing)*0)}.\@md\:w-1\/2{width:50%}.\@md\:flex-row{flex-direction:row}.\@md\:border-s:dir(ltr){border-left-style:var(--tw-border-style);border-left-width:1px}.\@md\:border-s:dir(rtl){border-right-style:var(--tw-border-style);border-right-width:1px}.\@md\:border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}}@container col (width>=32rem){.\@lg\/col\:col-span-6{grid-column:span 6/span 6}.\@lg\/col\:col-start-4{grid-column-start:4}}@container images-promo-banner (width>=32rem){.\@lg\/images-promo-banner\:flex{display:flex}}@container main (width>=40rem){.\@\[40rem\]\/main\:-start-7:dir(ltr){left:calc(var(--spacing)*-7)}.\@\[40rem\]\/main\:-start-7:dir(rtl){right:calc(var(--spacing)*-7)}.\@w-sm\/main\:-mx-4{margin-inline:calc(var(--spacing)*-4)}.\@\[40rem\]\/main\:block{display:block}.\@w-sm\/main\:flex{display:flex}.\@w-sm\/main\:w-\[calc\(100\%\+2rem\)\]{width:calc(100% + 2rem)}.\@w-sm\/main\:max-w-full{max-width:100%}.\@w-sm\/main\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.\@w-sm\/main\:flex-row{flex-direction:row}.\@w-sm\/main\:gap-2\.5{gap:calc(var(--spacing)*2.5)}.\@w-sm\/main\:px-2{padding-inline:calc(var(--spacing)*2)}.\@w-sm\/main\:px-4{padding-inline:calc(var(--spacing)*4)}.\@w-sm\/main\:\[--thread-content-margin\:var\(--thread-content-margin-sm\,calc\(var\(--spacing\)\*6\)\)\]{--thread-content-margin:var(--thread-content-margin-sm,calc(var(--spacing)*6))}.\@w-sm\/main\:\[scrollbar-gutter\:stable_both-edges\]{scrollbar-gutter:stable both-edges}}@container main (width>=42rem){.\@2xl\/main\:start-8:dir(ltr){left:calc(var(--spacing)*8)}.\@2xl\/main\:start-8:dir(rtl){right:calc(var(--spacing)*8)}.\@2xl\/main\:top-8{top:calc(var(--spacing)*8)}.\@2xl\/main\:bottom-5{bottom:calc(var(--spacing)*5)}.\@2xl\/main\:m-0{margin:calc(var(--spacing)*0)}.\@2xl\/main\:-mx-8{margin-inline:calc(var(--spacing)*-8)}.\@2xl\/main\:me-4:dir(ltr){margin-right:calc(var(--spacing)*4)}.\@2xl\/main\:me-4:dir(rtl){margin-left:calc(var(--spacing)*4)}.\@2xl\/main\:mt-2{margin-top:calc(var(--spacing)*2)}.\@2xl\/main\:block{display:block}.\@2xl\/main\:hidden{display:none}.\@2xl\/main\:h-1\.5{height:calc(var(--spacing)*1.5)}.\@2xl\/main\:h-5{height:calc(var(--spacing)*5)}.\@2xl\/main\:h-8{height:calc(var(--spacing)*8)}.\@2xl\/main\:h-12{height:calc(var(--spacing)*12)}.\@2xl\/main\:h-80{height:calc(var(--spacing)*80)}.\@2xl\/main\:h-full{height:100%}.\@2xl\/main\:min-h-12{min-height:calc(var(--spacing)*12)}.\@2xl\/main\:w-1\.5{width:calc(var(--spacing)*1.5)}.\@2xl\/main\:w-5{width:calc(var(--spacing)*5)}.\@2xl\/main\:w-8{width:calc(var(--spacing)*8)}.\@2xl\/main\:w-12{width:calc(var(--spacing)*12)}.\@2xl\/main\:w-62{width:calc(var(--spacing)*62)}.\@2xl\/main\:w-96{width:calc(var(--spacing)*96)}.\@2xl\/main\:max-w-64{max-width:calc(var(--spacing)*64)}.\@2xl\/main\:max-w-72{max-width:calc(var(--spacing)*72)}.\@2xl\/main\:max-w-96{max-width:calc(var(--spacing)*96)}.\@2xl\/main\:min-w-12{min-width:calc(var(--spacing)*12)}.\@2xl\/main\:min-w-20{min-width:calc(var(--spacing)*20)}.\@2xl\/main\:flex-1{flex:1}.\@2xl\/main\:flex-grow-0{flex-grow:0}.\@2xl\/main\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.\@2xl\/main\:grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.\@2xl\/main\:flex-col{flex-direction:column}.\@2xl\/main\:flex-row{flex-direction:row}}@container (width>=42rem){.\@2xl\:flex-row{flex-direction:row}.\@2xl\:justify-between{justify-content:space-between}}@container main (width>=42rem){.\@2xl\/main\:gap-12{gap:calc(var(--spacing)*12)}:where(.\@2xl\/main\:space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-top:calc(calc(var(--spacing)*.5)*var(--tw-space-y-reverse));margin-bottom:calc(calc(var(--spacing)*.5)*calc(1 - var(--tw-space-y-reverse)))}.\@2xl\/main\:gap-y-2{row-gap:calc(var(--spacing)*2)}.\@2xl\/main\:rounded-4xl{border-radius:var(--radius-4xl)}.\@2xl\/main\:mask-r-from-black{-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);--tw-mask-linear:var(--tw-mask-left),var(--tw-mask-right),var(--tw-mask-bottom),var(--tw-mask-top);--tw-mask-right:linear-gradient(to right,var(--tw-mask-right-from-color)var(--tw-mask-right-from-position),var(--tw-mask-right-to-color)var(--tw-mask-right-to-position));--tw-mask-right-from-color:#000;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}.\@2xl\/main\:p-3{padding:calc(var(--spacing)*3)}.\@2xl\/main\:p-8{padding:calc(var(--spacing)*8)}.\@2xl\/main\:px-8{padding-inline:calc(var(--spacing)*8)}.\@2xl\/main\:pe-16:dir(ltr){padding-right:calc(var(--spacing)*16)}.\@2xl\/main\:pe-16:dir(rtl){padding-left:calc(var(--spacing)*16)}.\@2xl\/main\:pt-8{padding-top:calc(var(--spacing)*8)}}@container (width>=42rem){.\@2xl\:text-start{text-align:start}}@container main (width>=42rem){.\@2xl\/main\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}}@container main (width>=43rem){.\@\[43rem\]\/main\:min-w-\[300px\]{min-width:300px}}@container main (width>=44rem){.\@\[44rem\]\/main\:-start-9:dir(ltr){left:calc(var(--spacing)*-9)}.\@\[44rem\]\/main\:-start-9:dir(rtl){right:calc(var(--spacing)*-9)}}@container (width>=48rem){.\@3xl\:-start-3:dir(ltr){left:calc(var(--spacing)*-3)}.\@3xl\:-start-3:dir(rtl){right:calc(var(--spacing)*-3)}.\@3xl\:-top-4{top:calc(var(--spacing)*-4)}}@container main (width>=64rem){.\@w-lg\/main\:min-w-\[360px\]{min-width:360px}.\@w-lg\/main\:\[--thread-content-margin\:var\(--thread-content-margin-lg\,calc\(var\(--spacing\)\*16\)\)\]{--thread-content-margin:var(--thread-content-margin-lg,calc(var(--spacing)*16))}.\@w-lg\/main\:\[--thread-content-max-width\:48rem\]{--thread-content-max-width:48rem}.\@w-lg\/main\:\[--thread-content-max-width\:52rem\]{--thread-content-max-width:52rem}}@container main (width>=80rem){.\@w-xl\/main\:top-0{top:calc(var(--spacing)*0)}.\@w-xl\/main\:top-2{top:calc(var(--spacing)*2)}.\@w-xl\/main\:top-4{top:calc(var(--spacing)*4)}.\@w-xl\/main\:block{display:block}.\@w-xl\/main\:hidden{display:none}.\@w-xl\/main\:flex-col{flex-direction:column}.\@w-xl\/main\:gap-3{gap:calc(var(--spacing)*3)}.\@w-xl\/main\:bg-token-main-surface-primary{background-color:var(--main-surface-primary)}.\@w-xl\/main\:\[box-shadow\:var\(--sharp-edge-top-shadow\)\]\!{box-shadow:var(--sharp-edge-top-shadow)!important}.has-data-\[fixed-header\=less-than-xl\]\:\@w-xl\/main\:scroll-pt-0:has([data-fixed-header=less-than-xl]){scroll-padding-top:calc(var(--spacing)*0)}.has-data-\[fixed-header\=less-than-xl\]\:\@w-xl\/main\:\[--sticky-padding-top\:0px\]:has([data-fixed-header=less-than-xl]){--sticky-padding-top:0px}.data-\[fixed-header\=less-than-xl\]\:\@w-xl\/main\:bg-transparent[data-fixed-header=less-than-xl]{background-color:#0000}.data-\[fixed-header\=less-than-xl\]\:\@w-xl\/main\:shadow-none\![data-fixed-header=less-than-xl]{--tw-shadow:0 0 transparent!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}}@container main (width>=96rem){.has-data-\[fixed-header\=less-than-xxl\]\:\@w-2xl\/main\:scroll-pt-0:has([data-fixed-header=less-than-xxl]){scroll-padding-top:calc(var(--spacing)*0)}.has-data-\[fixed-header\=less-than-xxl\]\:\@w-2xl\/main\:\[--sticky-padding-top\:0px\]:has([data-fixed-header=less-than-xxl]){--sticky-padding-top:0px}.data-\[fixed-header\=less-than-xxl\]\:\@w-2xl\/main\:bg-transparent[data-fixed-header=less-than-xxl]{background-color:#0000}.data-\[fixed-header\=less-than-xxl\]\:\@w-2xl\/main\:shadow-none\![data-fixed-header=less-than-xxl]{--tw-shadow:0 0 transparent!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}}.ltr\:me-auto:where(:dir(ltr),[dir=ltr],[dir=ltr] *):dir(ltr){margin-right:auto}.ltr\:me-auto:where(:dir(ltr),[dir=ltr],[dir=ltr] *):dir(rtl){margin-left:auto}.ltr\:hidden:where(:dir(ltr),[dir=ltr],[dir=ltr] *){display:none}.ltr\:-translate-x-1\/2:where(:dir(ltr),[dir=ltr],[dir=ltr] *){--tw-translate-x:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.ltr\:translate-x-0:where(:dir(ltr),[dir=ltr],[dir=ltr] *){--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.ltr\:translate-x-1\/2:where(:dir(ltr),[dir=ltr],[dir=ltr] *){--tw-translate-x:calc(1/2*100%);translate:var(--tw-translate-x)var(--tw-translate-y)}.ltr\:-rotate-90:where(:dir(ltr),[dir=ltr],[dir=ltr] *){rotate:-90deg}.rtl\:ms-auto:where(:dir(rtl),[dir=rtl],[dir=rtl] *):dir(ltr){margin-left:auto}.rtl\:ms-auto:where(:dir(rtl),[dir=rtl],[dir=rtl] *):dir(rtl){margin-right:auto}.rtl\:hidden:where(:dir(rtl),[dir=rtl],[dir=rtl] *){display:none}.rtl\:origin-top-right:where(:dir(rtl),[dir=rtl],[dir=rtl] *){transform-origin:100% 0}.rtl\:-translate-x-1:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.rtl\:-translate-x-1\/2:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--tw-translate-x:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.rtl\:-translate-x-full:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--tw-translate-x:-100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.rtl\:translate-x-0:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.rtl\:translate-x-1:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--tw-translate-x:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.rtl\:translate-x-1\/2:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--tw-translate-x:calc(1/2*100%);translate:var(--tw-translate-x)var(--tw-translate-y)}.rtl\:-scale-x-100:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--tw-scale-x:calc(100%*-1);scale:var(--tw-scale-x)var(--tw-scale-y)}.rtl\:rotate-90:where(:dir(rtl),[dir=rtl],[dir=rtl] *){rotate:90deg}.rtl\:rotate-\[10deg\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){rotate:10deg}.rtl\:cursor-e-resize:where(:dir(rtl),[dir=rtl],[dir=rtl] *){cursor:e-resize}.rtl\:cursor-w-resize:where(:dir(rtl),[dir=rtl],[dir=rtl] *){cursor:w-resize}.rtl\:items-start:where(:dir(rtl),[dir=rtl],[dir=rtl] *){align-items:flex-start}.rtl\:bg-gradient-to-l:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--tw-gradient-position:to left in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.rtl\:bg-gradient-to-r:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.rtl\:\[--end\:left\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--end:left}.rtl\:\[--start\:right\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *){--start:right}:is(.rtl\:\*\*\:data-\[header-icon\]\:rotate-\[-10deg\]:where(:dir(rtl),[dir=rtl],[dir=rtl] *) *)[data-header-icon]{rotate:-10deg}@media (min-width:48rem){.rtl\:md\:col-start-1:where(:dir(rtl),[dir=rtl],[dir=rtl] *){grid-column-start:1}.rtl\:md\:col-start-6:where(:dir(rtl),[dir=rtl],[dir=rtl] *){grid-column-start:6}.rtl\:md\:col-start-8:where(:dir(rtl),[dir=rtl],[dir=rtl] *){grid-column-start:8}}@media (min-width:64rem){.rtl\:lg\:mask-l-from-black:where(:dir(rtl),[dir=rtl],[dir=rtl] *){-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);--tw-mask-linear:var(--tw-mask-left),var(--tw-mask-right),var(--tw-mask-bottom),var(--tw-mask-top);--tw-mask-left:linear-gradient(to left,var(--tw-mask-left-from-color)var(--tw-mask-left-from-position),var(--tw-mask-left-to-color)var(--tw-mask-left-to-position));--tw-mask-left-from-color:#000;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}}@container main (width>=42rem){.rtl\:\@2xl\/main\:mask-l-from-black:where(:dir(rtl),[dir=rtl],[dir=rtl] *){-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);mask-image:var(--tw-mask-linear),var(--tw-mask-radial),var(--tw-mask-conic);--tw-mask-linear:var(--tw-mask-left),var(--tw-mask-right),var(--tw-mask-bottom),var(--tw-mask-top);--tw-mask-left:linear-gradient(to left,var(--tw-mask-left-from-color)var(--tw-mask-left-from-position),var(--tw-mask-left-to-color)var(--tw-mask-left-to-position));--tw-mask-left-from-color:#000;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}}.dark\:block:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){display:block}.dark\:hidden:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){display:none}.dark\:h-3\.5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){height:calc(var(--spacing)*3.5)}.dark\:w-3\.5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){width:calc(var(--spacing)*3.5)}.dark\:-translate-y-0:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-translate-y:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}:where(.dark\:divide-white\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))>:not(:last-child)){border-color:#ffffff0d;border-color:lab(100% -.0000298023 .0000119209/.05)}:where(.dark\:divide-white\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))>:not(:last-child)){border-color:#ffffff26;border-color:lab(100% -.0000298023 .0000119209/.15)}.dark\:border:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-style:var(--tw-border-style);border-width:1px}.dark\:border-e:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):dir(ltr){border-right-style:var(--tw-border-style);border-right-width:1px}.dark\:border-e:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):dir(rtl){border-left-style:var(--tw-border-style);border-left-width:1px}.dark\:border-t:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-top-style:var(--tw-border-style);border-top-width:1px}.dark\:border-b:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.dark\:\!border-gray-700:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--gray-700)!important}.dark\:border-\[\#4F4D79\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#4f4d79!important}.dark\:border-\[\#60a5fa66\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#60a5fa66}.dark\:border-\[\#0088FF\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#08f}.dark\:border-\[\#303030\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#303030}.dark\:border-\[\#444378\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#444378}.dark\:border-\[\#484777\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#484777}.dark\:border-\[\#FF9E6C\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#ff9e6c}.dark\:border-\[\#e7c85f\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#e7c85f}.dark\:border-\[rgba\(168\,198\,255\,0\.16\)\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#a8c6ff29!important}.dark\:border-black:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#000}.dark\:border-black\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#00000026;border-color:lab(0% 0 0/.15)}.dark\:border-black\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#0003;border-color:lab(0% 0 0/.2)}.dark\:border-blue-300:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--blue-300)}.dark\:border-blue-700:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--blue-700)}.dark\:border-blue-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--blue-800)}.dark\:border-gray-300:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--gray-300)}.dark\:border-gray-500:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--gray-500)}.dark\:border-gray-600:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--gray-600)}.dark\:border-gray-700:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--gray-700)}.dark\:border-gray-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--gray-800)}.dark\:border-gray-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--gray-900)}.dark\:border-green-500\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--green-500)}@supports (color:color-mix(in lab, red, red)){.dark\:border-green-500\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:color-mix(in oklab,var(--green-500)30%,transparent)}}.dark\:border-orange-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--orange-800)}.dark\:border-red-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--red-400)}.dark\:border-red-500:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:border-red-500\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:color-mix(in oklab,var(--red-500)30%,transparent)}}.dark\:border-red-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--red-800)}.dark\:border-red-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:border-red-900\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--red-900)}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:color-mix(in oklab,var(--red-900)60%,transparent)}}.dark\:border-token-bg-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--bg-tertiary)}.dark\:border-token-border-default:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--border-default)}.dark\:border-token-border-heavy:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--border-heavy)}.dark\:border-token-border-light:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:border-token-border-light\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--border-light)}@supports (color:color-mix(in lab, red, red)){.dark\:border-token-border-light\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:color-mix(in oklab,var(--border-light)20%,transparent)}}.dark\:border-token-border-medium:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--border-medium)}.dark\:border-token-border-medium\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--border-medium)!important}.dark\:border-token-border-status-warning:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--border-status-warning)}.dark\:border-token-border-xheavy:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--border-xheavy)}.dark\:border-token-border-xlight:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--border-xlight)}.dark\:border-token-interactive-border-secondary-default:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--interactive-border-secondary-default)}.dark\:border-token-main-surface-secondary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--main-surface-secondary)}.dark\:border-transparent:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#0000}.dark\:border-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#fff}.dark\:border-white\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#ffffff0d;border-color:lab(100% -.0000298023 .0000119209/.05)}.dark\:border-white\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#ffffff1a;border-color:lab(100% -.0000298023 .0000119209/.1)}.dark\:border-white\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#ffffff26;border-color:lab(100% -.0000298023 .0000119209/.15)}.dark\:border-white\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#fff3;border-color:lab(100% -.0000298023 .0000119209/.2)}.dark\:border-yellow-600\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--yellow-600)}@supports (color:color-mix(in lab, red, red)){.dark\:border-yellow-600\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:color-mix(in oklab,var(--yellow-600)40%,transparent)}}.dark\:border-yellow-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--yellow-800)}.dark\:border-x-token-border-xheavy:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-inline-color:var(--border-xheavy)}.dark\:border-t-black:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-top-color:#000}.dark\:border-t-transparent:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-top-color:#0000}.dark\:border-t-white\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-top-color:#ffffff0d;border-top-color:lab(100% -.0000298023 .0000119209/.05)}.dark\:border-b-token-border-heavy:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-bottom-color:var(--border-heavy)}.dark\:border-b-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-bottom-color:#fff}.dark\:prose-invert:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--tw-prose-invert-pre-bg);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.dark\:prose-invert:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)) :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)) code{background-color:#0000}.dark\:prose-invert:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)) :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--gray-700)}.dark\:\!bg-gray-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-900)!important}.dark\:bg-\(--theme-user-msg-text\)\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--theme-user-msg-text)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-\(--theme-user-msg-text\)\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--theme-user-msg-text)15%,transparent)}}.dark\:bg-\[\#\.\.\.\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#...}.dark\:bg-\[\#1B1B1D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#1b1b1d}.dark\:bg-\[\#1E1E1E\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#1e1e1e}.dark\:bg-\[\#1F1D48\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#1f1d48}.dark\:bg-\[\#2A4A6D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#2a4a6d}.dark\:bg-\[\#2C2B3E\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#2c2b3e}.dark\:bg-\[\#2F2E57\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#2f2e57!important}.dark\:bg-\[\#2a2a2a\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#2a2a2a}.dark\:bg-\[\#2a2a2a\]\/98:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#2a2a2afa;background-color:lab(17.062% -.0000298023 .0000119209/.98)}.dark\:bg-\[\#4C3D3D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#4c3d3d}.dark\:bg-\[\#6BBD6720\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#6bbd6720}.dark\:bg-\[\#6e1615\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#6e1615}.dark\:bg-\[\#8F8DF624\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#8f8df624}.dark\:bg-\[\#00000044\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#0004}.dark\:bg-\[\#60a5fa1f\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#60a5fa1f}.dark\:bg-\[\#273B4C\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#273b4c}.dark\:bg-\[\#003716\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#003716}.dark\:bg-\[\#09090b\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#09090b}.dark\:bg-\[\#101010\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#101010}.dark\:bg-\[\#171717\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#171717}.dark\:bg-\[\#212121\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#212121}.dark\:bg-\[\#262626\]\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#26262680;background-color:lab(15.1597% .0000149012 0/.5)}.dark\:bg-\[\#282841\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#282841}.dark\:bg-\[\#303030\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#303030}.dark\:bg-\[\#303030\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#303030!important}.dark\:bg-\[\#333333\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#333}.dark\:bg-\[\#353535\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#353535}.dark\:bg-\[\#373669\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#373669}.dark\:bg-\[\#393939\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#393939}.dark\:bg-\[\#444378\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#444378}.dark\:bg-\[\#B2B2B220\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#b2b2b220}.dark\:bg-\[\#C26FFD20\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#c26ffd20}.dark\:bg-\[\#EA8444\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ea8444}.dark\:bg-\[\#F3F3F3\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#f3f3f3}.dark\:bg-\[\#FD756F20\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#fd756f20}.dark\:bg-\[\#fae271\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#fae271}.dark\:bg-\[rgb\(48_48_48\/0\.7\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#303030b3}.dark\:bg-\[rgb\(74_222_128_\/_1\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#4ade80}.dark\:bg-\[rgb\(248_113_113_\/_1\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#f87171}.dark\:bg-\[rgba\(16\,28\,60\,0\.42\)\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#101c3c6b!important}.dark\:bg-\[rgba\(33\,33\,33\,1\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#212121}.dark\:bg-\[rgba\(38\,43\,61\,0\.5\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#262b3d80}.dark\:bg-\[rgba\(48\,48\,48\,0\.8\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#303030cc}.dark\:bg-\[rgba\(48\,48\,48\,1\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#303030}.dark\:bg-\[rgba\(255\,255\,255\,0\.04\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffff0a}.dark\:bg-\[rgba\(255\,255\,255\,0\.05\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffff0d}.dark\:bg-\[rgba\(255\,255\,255\,0\.12\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffff1f}.dark\:bg-\[rgba\(255\,255\,255\,0\.90\)\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffffe6!important}.dark\:bg-\[var\(--bg-tertiary\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-tertiary)}.dark\:bg-black:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#000}.dark\:bg-black\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#0000001a;background-color:lab(0% 0 0/.1)}.dark\:bg-black\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#0006;background-color:lab(0% 0 0/.4)}.dark\:bg-black\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#00000080;background-color:lab(0% 0 0/.5)}.dark\:bg-black\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#0009;background-color:lab(0% 0 0/.6)}.dark\:bg-black\/80:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#000c;background-color:lab(0% 0 0/.8)}.dark\:bg-black\/85:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#000000d9;background-color:lab(0% 0 0/.85)}.dark\:bg-blue-200\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--blue-200)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-200\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--blue-200)20%,transparent)}}.dark\:bg-blue-300\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--blue-300)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-300\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--blue-300)30%,transparent)}}.dark\:bg-blue-500:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--blue-500)}.dark\:bg-blue-600:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--blue-600)}.dark\:bg-blue-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-blue-900\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--blue-900)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--blue-900)40%,transparent)}}.dark\:bg-blue-950\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--blue-950)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--blue-950)30%,transparent)}}.dark\:bg-blue-950\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--blue-950)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--blue-950)40%,transparent)}}.dark\:bg-gray-50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-gray-50\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-50)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-50\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--gray-50)5%,transparent)}}.dark\:bg-gray-100:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-100)}.dark\:bg-gray-600:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-600)}.dark\:bg-gray-700:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-gray-700\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-700)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-700\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--gray-700)50%,transparent)}}.dark\:bg-gray-700\/75:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-700)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-700\/75:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--gray-700)75%,transparent)}}.dark\:bg-gray-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-gray-800\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-800)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-800\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--gray-800)70%,transparent)}}.dark\:bg-gray-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-900)}.dark\:bg-gray-950:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-950)}.dark\:bg-gray-1000:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-1000)}.dark\:bg-gray-solid-1000:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#0d0d0d}.dark\:bg-green-600:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-green-600\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--green-600)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-600\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--green-600)30%,transparent)}}.dark\:bg-green-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--green-800)}.dark\:bg-green-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-green-900\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--green-900)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--green-900)20%,transparent)}}.dark\:bg-green-900\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--green-900)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--green-900)40%,transparent)}}.dark\:bg-orange-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--orange-800)}.dark\:bg-orange-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--orange-900)}.dark\:bg-pink-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--pink-900)}.dark\:bg-purple-400\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--purple-400)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-purple-400\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--purple-400)30%,transparent)}}.dark\:bg-purple-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--purple-900)}.dark\:bg-red-500\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-500\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--red-500)10%,transparent)}}.dark\:bg-red-500\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-500\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--red-500)15%,transparent)}}.dark\:bg-red-500\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-500\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--red-500)30%,transparent)}}.dark\:bg-red-600:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-red-600\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--red-600)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-600\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--red-600)30%,transparent)}}.dark\:bg-red-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--red-800)}.dark\:bg-red-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-red-900\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--red-900)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--red-900)20%,transparent)}}.dark\:bg-red-900\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--red-900)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--red-900)30%,transparent)}}.dark\:bg-red-900\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--red-900)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--red-900)40%,transparent)}}.dark\:bg-red-900\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--red-900)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--red-900)60%,transparent)}}.dark\:bg-red-950\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--red-950)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--red-950)30%,transparent)}}.dark\:bg-token-bg-elevated-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-elevated-primary)}.dark\:bg-token-bg-elevated-secondary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-token-bg-elevated-secondary\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-elevated-secondary)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-bg-elevated-secondary\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--bg-elevated-secondary)70%,transparent)}}.dark\:bg-token-bg-elevated-secondary\/80:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-elevated-secondary)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-bg-elevated-secondary\/80:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--bg-elevated-secondary)80%,transparent)}}.dark\:bg-token-bg-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-token-bg-primary\/75:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-bg-primary\/75:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--bg-primary)75%,transparent)}}.dark\:bg-token-bg-secondary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-token-bg-secondary\/45:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-bg-secondary\/45:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--bg-secondary)45%,transparent)}}.dark\:bg-token-bg-secondary\/80:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-bg-secondary\/80:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--bg-secondary)80%,transparent)}}.dark\:bg-token-bg-secondary\/85:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-bg-secondary\/85:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--bg-secondary)85%,transparent)}}.dark\:bg-token-bg-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-tertiary)}.dark\:bg-token-bg-tertiary\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-tertiary)!important}.dark\:bg-token-bg-tertiary\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-bg-tertiary\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--bg-tertiary)40%,transparent)}}.dark\:bg-token-bg-tertiary\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-bg-tertiary\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--bg-tertiary)50%,transparent)}}.dark\:bg-token-bg-tertiary\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-bg-tertiary\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--bg-tertiary)60%,transparent)}}.dark\:bg-token-border-default:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--border-default)}.dark\:bg-token-border-heavy:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--border-heavy)}.dark\:bg-token-interactive-bg-secondary-press:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--interactive-bg-secondary-press)}.dark\:bg-token-main-surface-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--main-surface-primary)}.dark\:bg-token-main-surface-primary-inverse:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--main-surface-primary-inverse)}.dark\:bg-token-main-surface-primary\/20\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--main-surface-primary)!important}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-main-surface-primary\/20\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--main-surface-primary)20%,transparent)!important}}.dark\:bg-token-main-surface-secondary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-token-main-surface-secondary\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--main-surface-secondary)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-main-surface-secondary\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--main-surface-secondary)70%,transparent)}}.dark\:bg-token-main-surface-secondary\/80:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--main-surface-secondary)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-main-surface-secondary\/80:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--main-surface-secondary)80%,transparent)}}.dark\:bg-token-main-surface-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--main-surface-tertiary)}.dark\:bg-token-sidebar-surface-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--sidebar-surface-primary)}.dark\:bg-token-surface-error\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:rgb(var(--surface-error)/1)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-token-surface-error\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,rgb(var(--surface-error)/1)5%,transparent)}}.dark\:bg-token-text-inverted:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--text-inverted)}.dark\:bg-token-text-secondary\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--text-secondary)!important}.dark\:bg-token-text-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--text-tertiary)}.dark\:bg-transparent:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#0000}.dark\:bg-transparent\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#0000!important}.dark\:bg-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#fff}.dark\:bg-white\/3:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffff08;background-color:lab(100% -.0000298023 .0000119209/.03)}.dark\:bg-white\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffff0d;background-color:lab(100% -.0000298023 .0000119209/.05)}.dark\:bg-white\/6:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffff0f;background-color:lab(100% -.0000298023 .0000119209/.06)}.dark\:bg-white\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffff1a;background-color:lab(100% -.0000298023 .0000119209/.1)}.dark\:bg-white\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#fff3;background-color:lab(100% -.0000298023 .0000119209/.2)}.dark\:bg-white\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffff4d;background-color:lab(100% -.0000298023 .0000119209/.3)}.dark\:bg-yellow-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-yellow-400\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--yellow-400)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-400\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--yellow-400)30%,transparent)}}.dark\:bg-yellow-400\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--yellow-400)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-400\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--yellow-400)50%,transparent)}}.dark\:bg-yellow-500:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-yellow-500\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--yellow-500)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-500\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--yellow-500)10%,transparent)}}.dark\:bg-yellow-500\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--yellow-500)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-500\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--yellow-500)50%,transparent)}}.dark\:bg-yellow-500\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--yellow-500)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-500\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--yellow-500)70%,transparent)}}.dark\:bg-yellow-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:bg-yellow-900\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--yellow-900)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--yellow-900)20%,transparent)}}.dark\:bg-yellow-900\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--yellow-900)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--yellow-900)30%,transparent)}}.dark\:bg-yellow-900\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--yellow-900)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--yellow-900)40%,transparent)}}.dark\:bg-yellow-950:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--yellow-950)}.dark\:dark\:bg-\[\#303030\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#303030}.dark\:bg-linear-to-t:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-position:to top}@supports (background-image:linear-gradient(in lab, red, red)){.dark\:bg-linear-to-t:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-position:to top in oklab}}.dark\:bg-linear-to-t:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-image:linear-gradient(var(--tw-gradient-stops))}.dark\:bg-\[linear-gradient\(180deg\,oklch\(0\.5_0_0\/0\.3\)_80\%\,transparent_100\%\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-image:linear-gradient(#6363634d 80%,#0000 100%);background-image:linear-gradient(lab(42% 0 0/.3) 80%,#0000 100%)}.dark\:bg-\[linear-gradient\(206\.72deg\,_\#30305F_2\.34\%\,_\#212121_92\.37\%\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-image:linear-gradient(206.72deg,#30305f 2.34%,#212121 92.37%)}.dark\:bg-\[radial-gradient\(circle\,_\#4a4a4a_0\.75px\,_transparent_0\.75px\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-image:radial-gradient(circle,#4a4a4a .75px,#0000 .75px)}.dark\:bg-none:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-image:none}.dark\:from-\[\#000D19\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-from:#000d19;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:from-\[\#1A1400\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-from:#1a1400;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:from-\[\#1f1f1f\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-from:#1f1f1f;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:from-\[\#100A19\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-from:#100a19;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:from-\[\#F472B6\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-from:#f472b6;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:from-black\/24:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-from:#0000003d;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.dark\:from-black\/24:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-from:lab(0% 0 0/.24)}}.dark\:from-gray-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-from:var(--gray-800);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:from-token-bg-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-from:var(--bg-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:from-token-main-surface-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-from:var(--main-surface-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:via-\[\#2d2f3f\]\/42:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:#2d2f3f6b;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.dark\:via-\[\#2d2f3f\]\/42:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:lab(19.7328% 2.36222 -10.4691/.42)}}.dark\:via-\[\#C084FC\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:#c084fc;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.dark\:via-black\/42:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:#0000006b;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.dark\:via-black\/42:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:lab(0% 0 0/.42)}}.dark\:via-black\/65:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:#000000a6;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.dark\:via-black\/65:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:lab(0% 0 0/.65)}}.dark\:via-token-bg-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:var(--bg-primary);--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.dark\:via-token-bg-secondary\/85:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.dark\:via-token-bg-secondary\/85:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:color-mix(in oklab,var(--bg-secondary)85%,transparent)}}.dark\:via-token-bg-secondary\/85:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.dark\:via-token-main-surface-primary\/80:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.dark\:via-token-main-surface-primary\/80:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:color-mix(in oklab,var(--main-surface-primary)80%,transparent)}}.dark\:via-token-main-surface-primary\/80:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.dark\:via-white\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:#ffffff0d;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}@supports (color:lab(0% 0 0)){.dark\:via-white\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-via:lab(100% -.0000298023 .0000119209/.05)}}.dark\:to-\[\#2d2f3f\]\/82:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:#2d2f3fd1;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.dark\:to-\[\#2d2f3f\]\/82:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:lab(19.7328% 2.36222 -10.4691/.82)}}.dark\:to-\[\#170C26\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:#170c26;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:to-\[\#271D00\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:#271d00;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:to-\[\#818CF8\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:#818cf8;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:to-\[\#001223\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:#001223;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:to-\[var\(--bg-elevated-secondary\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:var(--bg-elevated-secondary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:to-black:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:#000;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:to-black\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:#0006;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.dark\:to-black\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:lab(0% 0 0/.4)}}.dark\:to-black\/88:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:#000000e0;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.dark\:to-black\/88:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:lab(0% 0 0/.88)}}.dark\:to-token-bg-secondary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:var(--bg-secondary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:to-transparent:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:to-white\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:#ffffff26;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}@supports (color:lab(0% 0 0)){.dark\:to-white\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-gradient-to:lab(100% -.0000298023 .0000119209/.15)}}.dark\:fill-\[rgba\(80\,80\,80\,1\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){fill:#505050}.dark\:fill-\[rgba\(255\,255\,255\,0\.04\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){fill:#ffffff0a}.dark\:fill-\[rgba\(255\,255\,255\,0\.05\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){fill:#ffffff0d}.dark\:fill-\[rgba\(255\,255\,255\,0\.06\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){fill:#ffffff0f}.dark\:fill-\[rgba\(255\,255\,255\,0\.12\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){fill:#ffffff1f}.dark\:fill-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){fill:#fff}.dark\:fill-white\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){fill:#ffffff80;fill:lab(100% -.0000298023 .0000119209/.5)}.dark\:stroke-\[\#3a3a3a\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#3a3a3a}.dark\:stroke-\[rgba\(0\,0\,0\,0\.32\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#00000052}.dark\:stroke-\[rgba\(255\,255\,255\,0\.1\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#ffffff1a}.dark\:stroke-\[rgba\(255\,255\,255\,0\.2\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#fff3}.dark\:stroke-\[rgba\(255\,255\,255\,0\.4\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#fff6}.dark\:stroke-\[rgba\(255\,255\,255\,0\.08\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#ffffff14}.dark\:stroke-\[rgba\(255\,255\,255\,0\.12\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#ffffff1f}.dark\:stroke-\[rgba\(255\,255\,255\,0\.14\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#ffffff24}.dark\:stroke-\[rgba\(255\,255\,255\,0\.16\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#ffffff29}.dark\:stroke-\[rgba\(255\,255\,255\,0\.18\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#ffffff2e}.dark\:stroke-black:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#000}.dark\:stroke-brand-purple\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#ab68ff80;stroke:lab(57.1209% 49.4506 -66.2104/.5)}.dark\:stroke-white\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#ffffff1a;stroke:lab(100% -.0000298023 .0000119209/.1)}.dark\:stroke-white\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){stroke:#fff3;stroke:lab(100% -.0000298023 .0000119209/.2)}.dark\:ps-2\.5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):dir(ltr){padding-left:calc(var(--spacing)*2.5)}.dark\:ps-2\.5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):dir(rtl){padding-right:calc(var(--spacing)*2.5)}.dark\:\!text-\[\#4fa6f7\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#4fa6f7!important}.dark\:\!text-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#fff!important}.dark\:text-\(--interactive-label-tertiary-default\):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--interactive-label-tertiary-default)}.dark\:text-\[\#0D0D0D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#0d0d0d}.dark\:text-\[\#5D5D5D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#5d5d5d}.dark\:text-\[\#6BBD67\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#6bbd67}.dark\:text-\[\#8F8DF6\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#8f8df6}.dark\:text-\[\#48AAFF\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#48aaff}.dark\:text-\[\#56d89c\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#56d89c}.dark\:text-\[\#66d492\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#66d492}.dark\:text-\[\#B2B2B2\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#b2b2b2}.dark\:text-\[\#B7B5FF\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#b7b5ff}.dark\:text-\[\#B9B7FF\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#b9b7ff}.dark\:text-\[\#C26FFD\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#c26ffd}.dark\:text-\[\#D292FF\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#d292ff}.dark\:text-\[\#DCDBF6\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#dcdbf6}.dark\:text-\[\#FD756F\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#fd756f}.dark\:text-\[\#FF9E6C\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#ff9e6c}.dark\:text-\[\#FF928C\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#ff928c}.dark\:text-\[\#afafaf\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#afafaf!important}.dark\:text-\[\#ff8583\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#ff8583}.dark\:text-\[rgba\(255\,255\,255\,0\.7\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#ffffffb3}.dark\:text-\[var\(--interactive-label-tertiary-default\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--interactive-label-tertiary-default)}.dark\:text-\[var\(--text-secondary\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--text-secondary)}.dark\:text-\[white\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#fff}.dark\:text-black:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#000}.dark\:text-black\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#0009;color:lab(0% 0 0/.6)}.dark\:text-blue-50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--blue-50)}.dark\:text-blue-75:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--blue-75)}.dark\:text-blue-100:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--blue-100)}.dark\:text-blue-200:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--blue-200)}.dark\:text-blue-300:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--blue-300)}.dark\:text-blue-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--blue-400)}.dark\:text-brand-purple-600:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#715fde}.dark\:text-gray-50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--gray-50)}.dark\:text-gray-100:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--gray-100)}.dark\:text-gray-200:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--gray-200)}.dark\:text-gray-300:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--gray-300)}.dark\:text-gray-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--gray-400)}.dark\:text-gray-400\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--gray-400)!important}.dark\:text-gray-500:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--gray-500)}.dark\:text-gray-700:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--gray-700)}.dark\:text-gray-950:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--gray-950)}.dark\:text-green-100:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--green-100)}.dark\:text-green-200:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--green-200)}.dark\:text-green-300:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--green-300)}.dark\:text-green-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--green-400)}.dark\:text-green-500:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--green-500)}.dark\:text-orange-200:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--orange-200)}.dark\:text-orange-300:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--orange-300)}.dark\:text-orange-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--orange-400)}.dark\:text-pink-200:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--pink-200)}.dark\:text-purple-200:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--purple-200)}.dark\:text-purple-300:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--purple-300)}.dark\:text-purple-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--purple-400)}.dark\:text-red-100:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--red-100)}.dark\:text-red-200:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--red-200)}.dark\:text-red-300:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--red-300)}.dark\:text-red-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--red-400)}.dark\:text-token-bg-primary\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.dark\:text-token-bg-primary\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:color-mix(in oklab,var(--bg-primary)60%,transparent)}}.dark\:text-token-bg-secondary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--bg-secondary)}.dark\:text-token-main-surface-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--main-surface-tertiary)}.dark\:text-token-text-inverted:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--text-inverted)}.dark\:text-token-text-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--text-primary)}.dark\:text-token-text-secondary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--text-secondary)}.dark\:text-token-text-status-warning:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--text-status-warning)}.dark\:text-token-text-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--text-tertiary)}.dark\:text-token-text-tertiary\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--text-tertiary)!important}.dark\:text-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#fff}.dark\:text-white\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#fff!important}.dark\:text-white\/35:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#ffffff59;color:lab(100% -.0000298023 .0000119209/.35)}.dark\:text-white\/55:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#ffffff8c;color:lab(100% -.0000298023 .0000119209/.55)}.dark\:text-white\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#ffffffb3;color:lab(100% -.0000298023 .0000119209/.7)}.dark\:text-yellow-100:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)),.dark\:text-yellow-100\/90:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--yellow-100)}@supports (color:color-mix(in lab, red, red)){.dark\:text-yellow-100\/90:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:color-mix(in oklab,var(--yellow-100)90%,transparent)}}.dark\:text-yellow-200:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--yellow-200)}.dark\:text-yellow-300:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--yellow-300)}.dark\:text-yellow-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--yellow-400)}.dark\:decoration-blue-500:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){-webkit-text-decoration-color:var(--blue-500);-webkit-text-decoration-color:var(--blue-500);-webkit-text-decoration-color:var(--blue-500);-webkit-text-decoration-color:var(--blue-500);text-decoration-color:var(--blue-500)}.dark\:decoration-red-500:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){-webkit-text-decoration-color:var(--red-500);-webkit-text-decoration-color:var(--red-500);-webkit-text-decoration-color:var(--red-500);-webkit-text-decoration-color:var(--red-500);text-decoration-color:var(--red-500)}.dark\:\[color-scheme\:dark\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--lightningcss-light: ;--lightningcss-dark:initial;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}.dark\:opacity-10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){opacity:.1}.dark\:opacity-20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){opacity:.2}.dark\:opacity-30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){opacity:.3}.dark\:opacity-40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){opacity:.4}.dark\:opacity-45:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){opacity:.45}.dark\:opacity-60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){opacity:.6}.dark\:opacity-65:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){opacity:.65}.dark\:opacity-95:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){opacity:.95}.dark\:mix-blend-lighten:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){mix-blend-mode:lighten}.dark\:shadow-long:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 8px 12px 0px var(--tw-shadow-color,var(--shadow-color-1,#00000014)),0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#0000009e));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-long:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 8px 16px 0px var(--tw-shadow-color,#00000052),inset 0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#fff3)),0px 0px 1px 0px var(--tw-shadow-color,#0000009e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_-4px_32px_rgba\(0\,0\,0\,0\.12\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 -4px 32px var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_0_18px_rgba\(0\,0\,0\,0\.48\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 0 18px var(--tw-shadow-color,#0000007a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_0_56px_2px_rgba\(76\,84\,196\,0\.28\)\,0_0_24px_0_rgba\(76\,84\,196\,0\.16\)\,0_8px_18px_-12px_rgba\(10\,12\,32\,0\.82\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 0 56px 2px var(--tw-shadow-color,#4c54c447),0 0 24px 0 var(--tw-shadow-color,#4c54c429),0 8px 18px -12px var(--tw-shadow-color,#0a0c20d1);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_0_70px_rgba\(255\,255\,255\,0\.12\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 0 70px var(--tw-shadow-color,#ffffff1f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_1px_2px_rgba\(0\,0\,0\,0\.2\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 1px 2px var(--tw-shadow-color,#0003);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_4px_12px_rgba\(0\,0\,0\,0\.4\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 4px 12px var(--tw-shadow-color,#0006);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_4px_25px_-5px_rgb\(8_12_35_\/_0\.6\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 4px 25px -5px var(--tw-shadow-color,#080c2399);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_6px_14px_0_rgba\(0\,0\,0\,0\.35\)\,0_12px_20px_0_rgba\(0\,0\,0\,0\.4\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 6px 14px 0 var(--tw-shadow-color,#00000059),0 12px 20px 0 var(--tw-shadow-color,#0006);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_6px_18px_0_rgba\(0\,0\,0\,0\.35\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 6px 18px 0 var(--tw-shadow-color,#00000059);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_8px_16px_0_rgba\(0\,0\,0\,0\.40\)\,inset_0_0_1px_0_rgba\(255\,255\,255\,0\.25\)\,0_0_1px_0_rgba\(0\,0\,0\,0\.60\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 8px 16px 0 var(--tw-shadow-color,#0006),inset 0 0 1px 0 var(--tw-shadow-color,#ffffff40),0 0 1px 0 var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_8px_20px_rgba\(0\,0\,0\,0\.35\)\,_0_0\.5px_1px_rgba\(0\,0\,0\,0\.6\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 8px 20px var(--tw-shadow-color,#00000059),0 .5px 1px var(--tw-shadow-color,#0009);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_10px_20px_-6px_rgb\(20_20_20_\/_0\.5\)\,inset_0_0_1px_rgb\(255_255_255_\/_0\.3\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 10px 20px -6px var(--tw-shadow-color,#14141480),inset 0 0 1px var(--tw-shadow-color,#ffffff4d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_16px_48px_rgba\(0\,0\,0\,0\.32\)\,0_0_32px_rgba\(0\,0\,0\,0\.24\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 16px 48px var(--tw-shadow-color,#00000052),0 0 32px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_16px_48px_rgba\(52\,168\,83\,0\.14\)\,0_0_32px_rgba\(52\,168\,83\,0\.12\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 16px 48px var(--tw-shadow-color,#34a85324),0 0 32px var(--tw-shadow-color,#34a8531f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_16px_48px_rgba\(66\,133\,244\,0\.14\)\,0_0_32px_rgba\(66\,133\,244\,0\.12\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 16px 48px var(--tw-shadow-color,#4285f424),0 0 32px var(--tw-shadow-color,#4285f41f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_16px_48px_rgba\(255\,67\,67\,0\.14\)\,0_0_32px_rgba\(255\,67\,67\,0\.12\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 16px 48px var(--tw-shadow-color,#ff434324),0 0 32px var(--tw-shadow-color,#ff43431f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0_32px_48px_rgba\(0\,0\,0\,0\.175\)\,_0_0_1px_rgba\(255\,255\,255\,0\.4\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 32px 48px var(--tw-shadow-color,#0000002d),0 0 1px var(--tw-shadow-color,#fff6);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0px_0px_0px_1px_\#414141\,0px_4px_14px_rgba\(0\,0\,0\,0\.24\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 0px 0px 1px var(--tw-shadow-color,#414141),0px 4px 14px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0px_0px_0px_1px_rgba\(255\,255\,255\,0\.12\)\,0px_2px_2px_rgba\(0\,0\,0\,0\.2\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 0px 0px 1px var(--tw-shadow-color,#ffffff1f),0px 2px 2px var(--tw-shadow-color,#0003);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0px_1px_1px_0px_var\(--shadow-color-1\,rgba\(0\,_0\,_0\,_0\.10\)\)\,inset_0px_0px_1px_0px_var\(--shadow-color-2\,rgba\(255\,_255\,_255\,_0\.2\)\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 1px 1px 0px var(--tw-shadow-color,var(--shadow-color-1,#0000001a)),inset 0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#fff3));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0px_4px_12px_0px_var\(--shadow-color-1\,rgba\(0\,_0\,_0\,_0\.24\)\)\,inset_0px_0px_1px_0px_var\(--shadow-color-2\,rgba\(255\,_255\,_255\,_0\.3\)\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 4px 12px 0px var(--tw-shadow-color,var(--shadow-color-1,#0000003d)),inset 0px 0px 1px 0px var(--tw-shadow-color,var(--shadow-color-2,#ffffff4d));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[0px_6px_18px_rgba\(0\,0\,0\,0\.14\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 6px 18px var(--tw-shadow-color,#00000024);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[inset_0_0_0_1px_rgba\(255\,255\,255\,0\.1\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#ffffff1a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-\[inset_0px_0px_1px_rgba\(255\,255\,255\,0\.3\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:inset 0px 0px 1px var(--tw-shadow-color,#ffffff4d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-md:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-none:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 0 transparent;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.dark\:shadow-none\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 0 transparent!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.dark\:shadow-black\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow-color:#0009}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow-color:color-mix(in oklab,lab(0% 0 0/.6) var(--tw-shadow-alpha),transparent)}}.dark\:ring-\[\#333333\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:#333}.dark\:ring-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:#fff}.dark\:ring-white\/2:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:#ffffff05}@supports (color:lab(0% 0 0)){.dark\:ring-white\/2:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:lab(100% -.0000298023 .0000119209/.02)}}.dark\:ring-white\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:#ffffff0d}@supports (color:lab(0% 0 0)){.dark\:ring-white\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:lab(100% -.0000298023 .0000119209/.05)}}.dark\:ring-white\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:#ffffff1a}@supports (color:lab(0% 0 0)){.dark\:ring-white\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:lab(100% -.0000298023 .0000119209/.1)}}.dark\:ring-white\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:#ffffff26}@supports (color:lab(0% 0 0)){.dark\:ring-white\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:lab(100% -.0000298023 .0000119209/.15)}}.dark\:ring-white\/\[0\.05\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:#ffffff0d}@supports (color:lab(0% 0 0)){.dark\:ring-white\/\[0\.05\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:lab(100% -.0000298023 .0000119209/.05)}}.dark\:ring-white\/\[0\.06\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:#ffffff0f}@supports (color:lab(0% 0 0)){.dark\:ring-white\/\[0\.06\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-ring-color:lab(100% -.0000298023 .0000119209/.06)}}.dark\:outline-\[\#444378\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){outline-color:#444378}.dark\:outline-\[\#484777\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){outline-color:#484777}.dark\:outline-black:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){outline-color:#000}.dark\:brightness-75:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-brightness:brightness(75%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.dark\:brightness-90:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-brightness:brightness(90%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.dark\:brightness-\[0\.78\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-brightness:brightness(.78);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.dark\:brightness-\[1\.55\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-brightness:brightness(1.55);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.dark\:contrast-\[1\.04\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-contrast:contrast(1.04);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.dark\:hue-rotate-180:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-hue-rotate:hue-rotate(180deg);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.dark\:invert:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-invert:invert(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.dark\:invert-0:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-invert:invert(0%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.dark\:saturate-\[0\.92\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-saturate:saturate(.92);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.dark\:\[filter\:grayscale\(1\)_brightness\(0\)_invert\(1\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){filter:grayscale()brightness(0)invert()}.dark\:backdrop-blur-lg:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-backdrop-blur:blur(var(--blur-lg));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.dark\:\[--canvas-bg\:var\(--bg-elevated-secondary\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--canvas-bg:var(--bg-elevated-secondary)}.dark\:\[--code-icon-c0\:\#BEBEBE\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--code-icon-c0:#bebebe}.dark\:\[--code-icon-c1\:\#4D4D4D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--code-icon-c1:#4d4d4d}.dark\:\[--code-icon-c2\:\#4D4D4D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--code-icon-c2:#4d4d4d}.dark\:\[--code-icon-c3\:\#4D4D4D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--code-icon-c3:#4d4d4d}.dark\:\[--constant-background-active\:rgba\(255\,255\,255\,0\.08\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--constant-background-active:#ffffff14}.dark\:\[--constant-background\:rgba\(255\,255\,255\,0\.04\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--constant-background:#ffffff0a}.dark\:\[--file-video-icon-c0\:\#DFDFDF\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--file-video-icon-c0:#dfdfdf}.dark\:\[--file-video-icon-c1\:\#FBFBFB\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--file-video-icon-c1:#fbfbfb}.dark\:\[--file-video-icon-c2\:\#4D4D4D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--file-video-icon-c2:#4d4d4d}.dark\:\[--file-video-icon-c3\:\#FBFBFB\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--file-video-icon-c3:#fbfbfb}.dark\:\[--file-video-icon-c4\:\#4D4D4D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--file-video-icon-c4:#4d4d4d}.dark\:\[--file-video-icon-c5\:\#4D4D4D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--file-video-icon-c5:#4d4d4d}.dark\:\[--jpg-icon-c0\:\#8EBAFF\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--jpg-icon-c0:#8ebaff}.dark\:\[--jpg-icon-c1\:\#AECDFF\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--jpg-icon-c1:#aecdff}.dark\:\[--mp4-icon-main-fill\:\#8AD56F\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--mp4-icon-main-fill:#8ad56f}.dark\:\[--pdf-icon-corner-fill\:\#FFB2AD\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--pdf-icon-corner-fill:#ffb2ad}.dark\:\[--pdf-icon-main-fill\:\#FF928C\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--pdf-icon-main-fill:#ff928c}.dark\:\[--photo-icon-c1\:\#FFF282\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--photo-icon-c1:#fff282}.dark\:\[--photo-icon-c2\:\#A6F546\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--photo-icon-c2:#a6f546}.dark\:\[--png-icon-c0\:\#62E2CC\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--png-icon-c0:#62e2cc}.dark\:\[--potion-icon-color\:\#FF928C\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--potion-icon-color:#ff928c}.dark\:\[--potion-medical-records-lines\:\#B0B0B0\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--potion-medical-records-lines:#b0b0b0}.dark\:\[--potion-medical-records-outer\:\#DA816B\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--potion-medical-records-outer:#da816b}.dark\:\[--potion-medical-records-tab\:\#A9583D\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--potion-medical-records-tab:#a9583d}.dark\:\[--ppt-icon-c0\:\#DFDFDF\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--ppt-icon-c0:#dfdfdf}.dark\:\[--ppt-icon-c2\:\#FDFDFD\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--ppt-icon-c2:#fdfdfd}.dark\:\[--right-bg\:black\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--right-bg:black}.dark\:\[--xls-icon-main-offset-0\:\#4AA647\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--xls-icon-main-offset-0:#4aa647}.dark\:\[--zip-icon-corner-fill\:\#E3E3E3\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--zip-icon-corner-fill:#e3e3e3}.dark\:\[--zip-icon-main-fill\:\#CACACA\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--zip-icon-main-fill:#cacaca}.dark\:\[--zip-icon-text-fill\:\#777777\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--zip-icon-text-fill:#777}.dark\:\[--zip-icon-zipper-center-fill\:\#E4E4E4\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--zip-icon-zipper-center-fill:#e4e4e4}.dark\:\[--zip-icon-zipper-fill\:\#8A8A8A\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--zip-icon-zipper-fill:#8a8a8a}.dark\:\[text-shadow\:-0\.8px_-0\.8px_0_\#000\,0\.8px_-0\.8px_0_\#000\,-0\.8px_0\.8px_0_\#000\,0\.8px_0\.8px_0_\#000\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){text-shadow:-.8px -.8px #000,.8px -.8px #000,-.8px .8px #000,.8px .8px #000}@media (hover:hover){.dark\:group-hover\:border-orange-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):is(:where(.group):hover *){border-color:var(--orange-800)}.dark\:group-hover\:border-token-text-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):is(:where(.group):hover *){border-color:var(--text-primary)}.dark\:group-hover\:bg-white\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):is(:where(.group):hover *){background-color:#ffffff0d;background-color:lab(100% -.0000298023 .0000119209/.05)}.dark\:group-hover\/btn\:bg-gray-100:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):is(:where(.group\/btn):hover *){background-color:var(--gray-100)}.dark\:group-hover\/icon\:bg-gray-600:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):is(:where(.group\/icon):hover *){background-color:var(--gray-600)}.dark\:group-hover\/row\:bg-gray-700:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):is(:where(.group\/row):hover *){background-color:var(--gray-700)}}.dark\:placeholder\:text-\[\#afafaf\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))::placeholder{color:#afafaf!important}.dark\:before\:bg-black\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):before{content:var(--tw-content);background-color:#0000004d;background-color:lab(0% 0 0/.3)}.dark\:before\:bg-black\/45:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):before{content:var(--tw-content);background-color:#00000073;background-color:lab(0% 0 0/.45)}.dark\:before\:bg-black\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):before{content:var(--tw-content);background-color:#00000080;background-color:lab(0% 0 0/.5)}.dark\:before\:bg-gray-750\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):before{content:var(--tw-content);background-color:var(--gray-750)}@supports (color:color-mix(in lab, red, red)){.dark\:before\:bg-gray-750\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):before{background-color:color-mix(in oklab,var(--gray-750)50%,transparent)}}.dark\:before\:bg-token-main-surface-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):before{content:var(--tw-content);background-color:var(--main-surface-tertiary)}.dark\:before\:bg-\[linear-gradient\(90deg\,rgba\(42\,74\,140\,0\.48\)_0\%\,rgba\(18\,30\,60\,0\.38\)_100\%\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):before{content:var(--tw-content);background-image:linear-gradient(90deg,#2a4a8c7a 0%,#121e3c61 100%)}.dark\:before\:opacity-65:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):before{content:var(--tw-content);opacity:.65}.dark\:after\:bg-\[Highlight\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):after{content:var(--tw-content);background-color:highlight}.dark\:after\:bg-\[linear-gradient\(180deg\,rgba\(0\,0\,0\,0\)_24\.327\%\,var\(--bg-primary\)_47\.029\%\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):after{content:var(--tw-content);background-image:linear-gradient(180deg,transparent 24.327%,var(--bg-primary)47.029%)}.dark\:after\:from-token-bg-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):after{content:var(--tw-content);--tw-gradient-from:var(--bg-primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:after\:via-token-bg-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):after{content:var(--tw-content);--tw-gradient-via:var(--bg-primary);--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.dark\:after\:to-transparent:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):after{content:var(--tw-content);--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.dark\:after\:invert:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):after{content:var(--tw-content);--tw-invert:invert(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.dark\:checked\:border-black:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):checked{border-color:#000}.dark\:checked\:border-blue-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):checked{border-color:var(--blue-400)}.dark\:checked\:border-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):checked{border-color:#fff}.dark\:checked\:bg-black:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):checked{background-color:#000}.dark\:checked\:bg-blue-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):checked{background-color:var(--blue-400)}.dark\:checked\:bg-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):checked{background-color:#fff}.dark\:indeterminate\:border-blue-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):indeterminate{border-color:var(--blue-400)}.dark\:indeterminate\:border-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):indeterminate{border-color:#fff}.dark\:indeterminate\:bg-blue-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):indeterminate{background-color:var(--blue-400)}.dark\:indeterminate\:bg-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):indeterminate{background-color:#fff}.dark\:focus-within\:border-red-500:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus-within{border-color:var(--red-500)}.dark\:focus-within\:border-token-border-xheavy:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus-within{border-color:var(--border-xheavy)}.dark\:focus-within\:bg-white\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus-within{background-color:#ffffff1a;background-color:lab(100% -.0000298023 .0000119209/.1)}.dark\:focus-within\:ring-0:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus-within{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}@media (hover:hover){.dark\:hover\:border-\[rgba\(168\,198\,255\,0\.32\)\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{border-color:#a8c6ff52!important}.dark\:hover\:bg-\[\#1A416A\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#1a416a}.dark\:hover\:bg-\[\#4D4C83\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#4d4c83}.dark\:hover\:bg-\[\#414071\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#414071}.dark\:hover\:bg-\[\#f6dc63\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#f6dc63}.dark\:hover\:bg-\[rgba\(38\,66\,132\,0\.55\)\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#2642848c!important}.dark\:hover\:bg-\[rgba\(255\,255\,255\,0\.1\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#ffffff1a}.dark\:hover\:bg-black\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#000000b3;background-color:lab(0% 0 0/.7)}.dark\:hover\:bg-blue-300\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--blue-300)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-300\/50:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:color-mix(in oklab,var(--blue-300)50%,transparent)}}.dark\:hover\:bg-blue-900\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--blue-900)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/40:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:color-mix(in oklab,var(--blue-900)40%,transparent)}}.dark\:hover\:bg-gray-100:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--gray-100)}.dark\:hover\:bg-gray-600:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--gray-600)}.dark\:hover\:bg-gray-700:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--gray-700)}.dark\:hover\:bg-gray-800:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--gray-800)}.dark\:hover\:bg-green-900\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--green-900)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-green-900\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:color-mix(in oklab,var(--green-900)30%,transparent)}}.dark\:hover\:bg-red-500\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-500\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:color-mix(in oklab,var(--red-500)15%,transparent)}}.dark\:hover\:bg-red-900\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--red-900)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:color-mix(in oklab,var(--red-900)30%,transparent)}}.dark\:hover\:bg-token-bg-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--bg-primary)}.dark\:hover\:bg-token-bg-secondary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--bg-secondary)}.dark\:hover\:bg-token-bg-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover,.dark\:hover\:bg-token-bg-tertiary\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-token-bg-tertiary\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:color-mix(in oklab,var(--bg-tertiary)60%,transparent)}}.dark\:hover\:bg-token-interactive-bg-secondary-hover:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--interactive-bg-secondary-hover)}.dark\:hover\:bg-token-main-surface-primary\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-token-main-surface-primary\/30:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:color-mix(in oklab,var(--main-surface-primary)30%,transparent)}}.dark\:hover\:bg-token-main-surface-secondary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--main-surface-secondary)}.dark\:hover\:bg-token-main-surface-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--main-surface-tertiary)}.dark\:hover\:bg-token-text-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--text-primary)}.dark\:hover\:bg-token-text-primary\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--text-primary)!important}.dark\:hover\:bg-token-text-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--text-tertiary)}.dark\:hover\:bg-transparent:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#0000}.dark\:hover\:bg-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#fff}.dark\:hover\:bg-white\/5:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#ffffff0d;background-color:lab(100% -.0000298023 .0000119209/.05)}.dark\:hover\:bg-white\/7:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#ffffff12;background-color:lab(100% -.0000298023 .0000119209/.07)}.dark\:hover\:bg-white\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#ffffff1a;background-color:lab(100% -.0000298023 .0000119209/.1)}.dark\:hover\:bg-white\/10\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#ffffff1a!important;background-color:lab(100% -.0000298023 .0000119209/.1)!important}.dark\:hover\:bg-white\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#fff3;background-color:lab(100% -.0000298023 .0000119209/.2)}.hover\:dark\:bg-gray-100\/10:hover:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-100)}@supports (color:color-mix(in lab, red, red)){.hover\:dark\:bg-gray-100\/10:hover:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--gray-100)10%,transparent)}}.dark\:hover\:text-blue-300:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{color:var(--blue-300)}.dark\:hover\:text-token-main-surface-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{color:var(--main-surface-tertiary)}.dark\:hover\:text-token-text-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{color:var(--text-primary)}.dark\:hover\:text-token-text-primary\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{color:var(--text-primary)!important}.dark\:hover\:opacity-100:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{opacity:1}}.dark\:focus\:border-red-500:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus{border-color:var(--red-500)}.dark\:focus\:border-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus{border-color:#fff}.dark\:focus\:bg-gray-50\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus{background-color:var(--gray-50)!important}.dark\:focus\:ring-\[\#ffffff\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus{--tw-ring-color:#fff!important}.dark\:focus\:ring-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus{--tw-ring-color:#fff}.dark\:focus-visible\:border-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus-visible{border-color:#fff}.dark\:focus-visible\:bg-token-text-primary\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus-visible{background-color:var(--text-primary)!important}.dark\:focus-visible\:ring-token-text-primary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus-visible{--tw-ring-color:var(--text-primary)}.dark\:focus-visible\:ring-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus-visible{--tw-ring-color:#fff}.dark\:focus-visible\:outline-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):focus-visible{outline-color:#fff}.dark\:active\:bg-\[\#f0d35a\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):active{background-color:#f0d35a}.dark\:active\:bg-gray-600:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):active{background-color:var(--gray-600)}.dark\:active\:bg-red-500\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):active{background-color:var(--red-500)}@supports (color:color-mix(in lab, red, red)){.dark\:active\:bg-red-500\/20:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):active{background-color:color-mix(in oklab,var(--red-500)20%,transparent)}}.dark\:active\:bg-token-interactive-bg-secondary-press:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):active{background-color:var(--interactive-bg-secondary-press)}.dark\:active\:bg-token-main-surface-primary\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):active{background-color:var(--main-surface-primary)}@supports (color:color-mix(in lab, red, red)){.dark\:active\:bg-token-main-surface-primary\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):active{background-color:color-mix(in oklab,var(--main-surface-primary)70%,transparent)}}.dark\:active\:bg-white\/8:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):active{background-color:#ffffff14;background-color:lab(100% -.0000298023 .0000119209/.08)}.dark\:active\:bg-white\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):active{background-color:#ffffff1a;background-color:lab(100% -.0000298023 .0000119209/.1)}@media (hover:hover){.dark\:enabled\:hover\:bg-white\/10:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):enabled:hover{background-color:#ffffff1a;background-color:lab(100% -.0000298023 .0000119209/.1)}}.dark\:disabled\:bg-white\/25:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):disabled{background-color:#ffffff40;background-color:lab(100% -.0000298023 .0000119209/.25)}.dark\:disabled\:text-white\/70:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):disabled{color:#ffffffb3;color:lab(100% -.0000298023 .0000119209/.7)}@media (hover:hover){.dark\:data-no-hover-bg\:hover\:bg-transparent:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))[data-no-hover-bg]:hover{background-color:#0000}}.data-\[state\=active\]\:dark\:bg-token-bg-tertiary\/60[data-state=active]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.data-\[state\=active\]\:dark\:bg-token-bg-tertiary\/60[data-state=active]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:color-mix(in oklab,var(--bg-tertiary)60%,transparent)}}.data-\[state\=active\]\:dark\:text-token-text-primary[data-state=active]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:var(--text-primary)}.dark\:data-\[state\=checked\]\:border-token-border-heavy:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))[data-state=checked]{border-color:var(--border-heavy)}.dark\:data-\[state\=checked\]\:border-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))[data-state=checked]{border-color:#fff}.dark\:data-\[state\=checked\]\:bg-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))[data-state=checked]{background-color:#fff}@supports ((-webkit-backdrop-filter:var(--tw)) or (backdrop-filter:var(--tw))){.dark\:supports-\[backdrop-filter\]\:bg-black\/60:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#0009;background-color:lab(0% 0 0/.6)}.dark\:supports-\[backdrop-filter\]\:bg-white\/15:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffff26;background-color:lab(100% -.0000298023 .0000119209/.15)}}@media (min-width:40rem){.sm\:dark\:shadow-\[0px_4px_48px_rgba\(0\,0\,0\,0\.2\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0px 4px 48px var(--tw-shadow-color,#0003);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.sm\:dark\:shadow-none:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){--tw-shadow:0 0 transparent;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}@media (min-width:48rem){.md\:dark\:border-gray-700:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:var(--gray-700)}.md\:dark\:border-transparent:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){border-color:#0000}.dark\:md\:bg-transparent:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#0000}.md\:dark\:bg-white\/\[0\.018\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffff05;background-color:lab(100% -.0000298023 .0000119209/.018)}@media (hover:hover){.dark\:md\:hover\:bg-gray-700:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:var(--gray-700)}}}@starting-style{.starting\:opacity-0{opacity:0}}@starting-style{.starting\:opacity-100{opacity:1}}@starting-style{.starting\:backdrop-blur-none{--tw-backdrop-blur: ;-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}}.before\:starting\:opacity-0:before{content:var(--tw-content)}@starting-style{.before\:starting\:opacity-0:before{opacity:0}}.before\:starting\:opacity-100:before{content:var(--tw-content)}@starting-style{.before\:starting\:opacity-100:before{opacity:1}}.before\:starting\:backdrop-blur-\[1px\]:before{content:var(--tw-content)}@starting-style{.before\:starting\:backdrop-blur-\[1px\]:before{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}}@media (min-width:40rem){@starting-style{.sm\:starting\:opacity-0{opacity:0}}}@media print{.print\:hidden{display:none}.print\:border-none{--tw-border-style:none;border-style:none}.print\:pt-2{padding-top:calc(var(--spacing)*2)}.print\:shadow-none{--tw-shadow:0 0 transparent;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.prose-headings\:mt-6 :where(h1,h2,h3,h4,h5,h6,th):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:calc(var(--spacing)*6)}.prose-headings\:mb-2 :where(h1,h2,h3,h4,h5,h6,th):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:calc(var(--spacing)*2)}.prose-headings\:text-\[1em\] :where(h1,h2,h3,h4,h5,h6,th):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1em}.prose-p\:my-0 :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-block:calc(var(--spacing)*0)}.prose-p\:my-1 :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-block:calc(var(--spacing)*1)}.prose-p\:text-body-regular :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:var(--text-body-regular);line-height:var(--tw-leading,var(--text-body-regular--line-height));letter-spacing:var(--tw-tracking,var(--text-body-regular--letter-spacing));font-weight:var(--tw-font-weight,var(--text-body-regular--font-weight))}.prose-p\:leading-7 :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){--tw-leading:calc(var(--spacing)*7);line-height:calc(var(--spacing)*7)}.prose-pre\:my-0 :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-ol\:my-0 :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-block:calc(var(--spacing)*0)}.prose-ol\:my-1 :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-block:calc(var(--spacing)*1)}.prose-ul\:my-0 :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-block:calc(var(--spacing)*0)}.prose-ul\:my-1 :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-block:calc(var(--spacing)*1)}.radix-disabled\:pointer-events-none[data-disabled]{pointer-events:none}.radix-disabled\:cursor-auto[data-disabled]{cursor:auto}.radix-disabled\:bg-transparent[data-disabled]{background-color:#0000}.radix-disabled\:text-token-text-tertiary[data-disabled]{color:var(--text-tertiary)}.radix-disabled\:opacity-50[data-disabled]{opacity:.5}.dark\:radix-disabled\:bg-transparent:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))[data-disabled]{background-color:#0000}.radix-state-active\:bg-white[data-state=active]{background-color:#fff}.radix-state-active\:text-token-text-primary[data-state=active]{color:var(--text-primary)}.radix-state-active\:text-token-text-secondary[data-state=active]{color:var(--text-secondary)}@media (min-width:48rem){.md\:radix-state-active\:bg-token-main-surface-tertiary[data-state=active]{background-color:var(--main-surface-tertiary)}.md\:radix-state-active\:text-token-text-primary[data-state=active]{color:var(--text-primary)}}.dark\:radix-state-active\:bg-token-main-surface-tertiary:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))[data-state=active]{background-color:var(--main-surface-tertiary)}.radix-state-checked\:translate-x-\[calc\(var\(--to-end-unit\,1\)\*100\%\*\(7\/4-1\)\)\][data-state=checked]{--tw-translate-x:calc(var(--to-end-unit,1)*100%*(7/4 - 1));translate:var(--tw-translate-x)var(--tw-translate-y)}.radix-state-checked\:border[data-state=checked]{border-style:var(--tw-border-style);border-width:1px}.radix-state-checked\:border-token-text-tertiary[data-state=checked]{border-color:var(--text-tertiary)}.radix-state-checked\:bg-black\![data-state=checked]{background-color:#000!important}.radix-state-checked\:bg-blue-400[data-state=checked]{background-color:var(--blue-400)}.radix-state-checked\:bg-token-main-surface-primary[data-state=checked]{background-color:var(--main-surface-primary)}.radix-state-checked\:bg-token-text-primary[data-state=checked]{background-color:var(--text-primary)}.radix-state-checked\:font-semibold[data-state=checked]{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.radix-state-checked\:text-token-main-surface-primary[data-state=checked]{color:var(--main-surface-primary)}.radix-state-checked\:text-token-text-primary[data-state=checked]{color:var(--text-primary)}.radix-state-checked\:shadow-\[0_0_2px_rgba\(0\,0\,0\,\.03\)\][data-state=checked]{--tw-shadow:0 0 2px var(--tw-shadow-color,#00000008);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.radix-state-open\:animate-alertShow[data-state=open]{animation:.15s cubic-bezier(.16,1,.3,1) alertShow}.radix-state-open\:animate-contentShow[data-state=open]{animation:.15s cubic-bezier(.16,1,.3,1) contentShow}.radix-state-open\:animate-show[data-state=open]{animation:.1s cubic-bezier(.16,1,.3,1) show}.radix-state-open\:border-token-text-primary[data-state=open]{border-color:var(--text-primary)}.radix-state-open\:bg-black\/10[data-state=open]{background-color:#0000001a;background-color:lab(0% 0 0/.1)}.radix-state-open\:bg-token-bg-tertiary[data-state=open]{background-color:var(--bg-tertiary)}.radix-state-open\:bg-token-interactive-bg-secondary-hover[data-state=open]{background-color:var(--interactive-bg-secondary-hover)}.radix-state-open\:bg-token-interactive-bg-secondary-press[data-state=open]{background-color:var(--interactive-bg-secondary-press)}.radix-state-open\:bg-token-main-surface-secondary[data-state=open]{background-color:var(--main-surface-secondary)}.radix-state-open\:bg-token-surface-hover[data-state=open]{background-color:var(--surface-hover)}.radix-state-open\:text-token-icon-primary[data-state=open]{color:var(--icon-primary)}.radix-state-open\:text-token-text-primary[data-state=open]{color:var(--text-primary)}.radix-state-open\:text-token-text-secondary[data-state=open]{color:var(--text-secondary)}.radix-state-open\:text-token-text-tertiary[data-state=open]{color:var(--text-tertiary)}.radix-state-open\:opacity-100[data-state=open]{opacity:1}.dark\:radix-state-open\:text-gray-400:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))[data-state=open]{color:var(--gray-400)}.radix-side-bottom\:flex-col-reverse[data-side=bottom]{flex-direction:column-reverse}@media (-webkit-min-device-pixel-ratio:1.5),(min-resolution:1.5dppx){.hires\:\[border-inline-width\:0\.5px\]{border-left-width:.5px;border-right-width:.5px}}@media (min-height:700px){.tall\:sticky{position:-webkit-sticky;position:sticky}.tall\:top-header-height{top:var(--header-height)}.tall\:z-20{z-index:20}.tall\:h-\[680px\]{height:680px}.tall\:gap-8{gap:calc(var(--spacing)*8)}.tall\:gap-16{gap:calc(var(--spacing)*16)}.tall\:\[box-shadow\:var\(--sharp-edge-top-shadow-placeholder\)\]{box-shadow:var(--sharp-edge-top-shadow-placeholder)}.tall\:\[--file-tile-action-size\:1\.5rem\]{--file-tile-action-size:1.5rem}.tall\:\[--file-tile-width\:9rem\]{--file-tile-width:9rem}.tall\:group-data-scrolled-from-top\/scrollport\:\[box-shadow\:var\(--sharp-edge-top-shadow\)\]:is(:where(.group\/scrollport)[data-scrolled-from-top] *){box-shadow:var(--sharp-edge-top-shadow)}}@media (min-width:40rem){@media (min-height:700px){.sm\:tall\:mb-4{margin-bottom:calc(var(--spacing)*4)}.sm\:tall\:max-h-\[750px\]{max-height:750px}.sm\:tall\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:tall\:font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}}}@media not all and (min-height:700px){.short\:h-\[calc\(var\(--header-height\)\+2px\)\]{height:calc(var(--header-height) + 2px)}.short\:bg-token-bg-primary\/10{background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.short\:bg-token-bg-primary\/10{background-color:color-mix(in oklab,var(--bg-primary)10%,transparent)}}.short\:backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}}@media not all and (min-width:40rem){@media not all and (min-height:700px){.max-sm\:short\:min-h-\[116px\]{min-height:116px}}}@media (min-width:40rem){@media not all and (min-height:700px){.sm\:short\:mt-4{margin-top:calc(var(--spacing)*4)}}}@media (hover:hover){.can-hover\:pointer-events-none{pointer-events:none}.can-hover\:absolute{position:absolute}.can-hover\:inset-y-0{inset-block:calc(var(--spacing)*0)}.can-hover\:end-0:dir(ltr){right:calc(var(--spacing)*0)}.can-hover\:end-0:dir(rtl){left:calc(var(--spacing)*0)}.can-hover\:opacity-0{opacity:0}.can-hover\:not-group-hover\:hidden:not(:is(:where(.group):hover *)){display:none}@media not all and (hover:hover){.can-hover\:not-group-hover\:hidden{display:none}}@media (hover:hover){.can-hover\:group-hover\:pointer-events-auto:is(:where(.group):hover *){pointer-events:auto}.can-hover\:group-hover\:pointer-events-none:is(:where(.group):hover *){pointer-events:none}.can-hover\:group-hover\:opacity-0:is(:where(.group):hover *){opacity:0}.can-hover\:group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.can-hover\:group-hover\/project-item\:pointer-events-auto:is(:where(.group\/project-item):hover *){pointer-events:auto}.can-hover\:group-hover\/project-item\:opacity-0:is(:where(.group\/project-item):hover *){opacity:0}.can-hover\:group-hover\/project-item\:opacity-100:is(:where(.group\/project-item):hover *){opacity:1}.can-hover\:hover\:bg-\[rgba\(0\,0\,0\,0\.06\)\]:hover{background-color:#0000000f}.can-hover\:hover\:bg-token-bg-secondary\/30:hover{background-color:var(--bg-secondary)}@supports (color:color-mix(in lab, red, red)){.can-hover\:hover\:bg-token-bg-secondary\/30:hover{background-color:color-mix(in oklab,var(--bg-secondary)30%,transparent)}}.can-hover\:hover\:bg-token-bg-tertiary:hover,.can-hover\:hover\:bg-token-bg-tertiary\/50:hover{background-color:var(--bg-tertiary)}@supports (color:color-mix(in lab, red, red)){.can-hover\:hover\:bg-token-bg-tertiary\/50:hover{background-color:color-mix(in oklab,var(--bg-tertiary)50%,transparent)}}.can-hover\:hover\:text-token-interactive-label-accent-hover:hover{color:var(--interactive-label-accent-hover)}.can-hover\:hover\:text-token-text-secondary:hover{color:var(--text-secondary)}}.can-hover\:active\:scale-\[0\.98\]:active{scale:.98}@media (hover:hover){.dark\:can-hover\:hover\:bg-\[rgba\(255\,255\,255\,0\.16\)\]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#ffffff29}}}@media (hover:none){.cant-hover\:pointer-events-auto{pointer-events:auto}.cant-hover\:hidden{display:none}.cant-hover\:gap-1{gap:calc(var(--spacing)*1)}.cant-hover\:gap-1\.5{gap:calc(var(--spacing)*1.5)}.cant-hover\:px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.cant-hover\:opacity-100{opacity:1}}.screen-arch .screen-arch\:static{position:static}.screen-arch .screen-arch\:top-12{top:calc(var(--spacing)*12)}.screen-arch .screen-arch\:flex{display:flex}.screen-arch .screen-arch\:hidden{display:none}.screen-arch .screen-arch\:min-h-\[calc\(100dvh-var\(--thread-leading-height\)-var\(--thread-trailing-height\)-12px\)\]{min-height:calc(100dvh - var(--thread-leading-height) - var(--thread-trailing-height) - 12px)}.screen-arch .screen-arch\:w-full{width:100%}.screen-arch .screen-arch\:items-center{align-items:center}.screen-arch .screen-arch\:justify-evenly{justify-content:space-evenly}.screen-arch .screen-arch\:bg-none{background-image:none}@media (min-width:48rem){.screen-arch .md\:screen-arch\:flex{display:flex}}.keyboard-open .keyboard-open\:fixed{position:fixed}.keyboard-open .keyboard-open\:start-3:dir(ltr){left:calc(var(--spacing)*3)}.keyboard-open .keyboard-open\:start-3:dir(rtl){right:calc(var(--spacing)*3)}.keyboard-open .keyboard-open\:end-3:dir(ltr){right:calc(var(--spacing)*3)}.keyboard-open .keyboard-open\:end-3:dir(rtl){left:calc(var(--spacing)*3)}.keyboard-open .keyboard-open\:bottom-\[calc\(var\(--screen-keyboard-height\,0px\)\+var\(--composer-height\,100px\)\+12px\)\]{bottom:calc(var(--screen-keyboard-height,0px) + var(--composer-height,100px) + 12px)}.keyboard-open .keyboard-open\:bottom-\[var\(--screen-keyboard-height\,0\)\]{bottom:var(--screen-keyboard-height,0)}.keyboard-open .keyboard-open\:z-50{z-index:50}.keyboard-open .keyboard-open\:h-\[calc\(100\%-var\(--screen-keyboard-height\,0px\)-var\(--composer-height\,100px\)\)\]{height:calc(100% - var(--screen-keyboard-height,0px) - var(--composer-height,100px))}.keyboard-open .keyboard-open\:h-\[var\(--screen-height-override\,calc\(var\(--cqh-full\)-env\(keyboard-inset-height\,0px\)-var\(--screen-height-offset\,0px\)-var\(--force-redraw\,0px\)\)\)\]{height:var(--screen-height-override,calc(var(--cqh-full) - env(keyboard-inset-height,0px) - var(--screen-height-offset,0px) - var(--force-redraw,0px)))}.keyboard-open .keyboard-open\:w-auto\!{width:auto!important}.keyboard-open .keyboard-open\:-translate-y-2{--tw-translate-y:calc(var(--spacing)*-2);translate:var(--tw-translate-x)var(--tw-translate-y)}.keyboard-open .keyboard-open\:px-4{padding-inline:calc(var(--spacing)*4)}.keyboard-open .keyboard-open\:pb-\[calc\(var\(--composer-height\,100px\)\+var\(--screen-keyboard-height\,0\)\)\]{padding-bottom:calc(var(--composer-height,100px) + var(--screen-keyboard-height,0))}.panel-has-scrolled\:\[box-shadow\:var\(--sharp-edge-top-shadow\)\].panel-has-scrolled{box-shadow:var(--sharp-edge-top-shadow)}.panel-is-scrolling-to-end\:\[box-shadow\:var\(--sharp-edge-bottom-shadow\)\].panel-is-scrolling-to-end{box-shadow:var(--sharp-edge-bottom-shadow)}@media (pointer:coarse){.touch\:pointer-events-auto{pointer-events:auto}.touch\:-ms-3\.5:dir(ltr){margin-left:calc(var(--spacing)*-3.5)}.touch\:-ms-3\.5:dir(rtl){margin-right:calc(var(--spacing)*-3.5)}.touch\:-me-2:dir(ltr){margin-right:calc(var(--spacing)*-2)}.touch\:-me-2:dir(rtl){margin-left:calc(var(--spacing)*-2)}.touch\:hidden{display:none}.touch\:h-3{height:calc(var(--spacing)*3)}.touch\:h-10{height:calc(var(--spacing)*10)}.touch\:min-h-10{min-height:calc(var(--spacing)*10)}.touch\:w-3{width:calc(var(--spacing)*3)}.touch\:w-10{width:calc(var(--spacing)*10)}.touch\:w-\[32px\]{width:32px}.touch\:w-\[calc\(100\%\+--spacing\(3\.5\)\)\]{width:calc(100% + calc(var(--spacing)*3.5))}.touch\:p-2\.5{padding:calc(var(--spacing)*2.5)}.touch\:px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.touch\:px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.touch\:opacity-100{opacity:1}.touch\:\[scrollbar-width\:none\]{scrollbar-width:none}}.group\/component-group[data-multi-columns=true] .multi-columns\:mt-4{margin-top:calc(var(--spacing)*4)}.group\/component-group[data-multi-columns=true] .multi-columns\:block{display:block}.group\/component-group[data-multi-columns=true] .multi-columns\:flex{display:flex}.group\/component-group[data-multi-columns=true] .multi-columns\:px-0{padding-inline:calc(var(--spacing)*0)}@media (min-width:48rem){[data-full-grid-content=true] .full-grid-content\:md\:col-span-12{grid-column:span 12/span 12}[data-full-grid-content=true] .full-grid-content\:md\:col-start-1{grid-column-start:1}}.keyboard-focused\:bg-token-bg-primary\/80:is(html[data-focus-mode=keyboard] :focus-visible){background-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.keyboard-focused\:bg-token-bg-primary\/80:is(html[data-focus-mode=keyboard] :focus-visible){background-color:color-mix(in oklab,var(--bg-primary)80%,transparent)}}.keyboard-focused\:bg-token-bg-secondary:is(html[data-focus-mode=keyboard] :focus-visible){background-color:var(--bg-secondary)}.keyboard-focused\:bg-token-interactive-bg-secondary-hover:is(html[data-focus-mode=keyboard] :focus-visible){background-color:var(--interactive-bg-secondary-hover)}.keyboard-focused\:bg-token-main-surface-secondary:is(html[data-focus-mode=keyboard] :focus-visible){background-color:var(--main-surface-secondary)}.keyboard-focused\:bg-token-surface-hover:is(html[data-focus-mode=keyboard] :focus-visible){background-color:var(--surface-hover)}.keyboard-focused\:bg-transparent:is(html[data-focus-mode=keyboard] :focus-visible){background-color:#0000}.keyboard-focused\:focus-ring:is(html[data-focus-mode=keyboard] :focus-visible){outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}.keyboard-focused\:outline:is(html[data-focus-mode=keyboard] :focus-visible){outline-style:var(--tw-outline-style);outline-width:1px}.keyboard-focused\:outline-\[2px\]:is(html[data-focus-mode=keyboard] :focus-visible){outline-style:var(--tw-outline-style);outline-width:2px}.keyboard-focused\:outline-offset-\[1\.5px\]:is(html[data-focus-mode=keyboard] :focus-visible){outline-offset:1.5px}.keyboard-focused\:outline-black:is(html[data-focus-mode=keyboard] :focus-visible){outline-color:#000}.keyboard-focused\:brightness-95:is(html[data-focus-mode=keyboard] :focus-visible){--tw-brightness:brightness(95%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.dark\:keyboard-focused\:outline-offset-\[2\.5px\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):is(html[data-focus-mode=keyboard] :focus-visible){outline-offset:2.5px!important}.dark\:keyboard-focused\:outline-white\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):is(html[data-focus-mode=keyboard] :focus-visible){outline-color:#fff!important}.keyboard-not-focused\:sr-only:not(html[data-focus-mode=keyboard] :focus-visible){clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.keyboard-not-focused\:outline-none:not(html[data-focus-mode=keyboard] :focus-visible){--tw-outline-style:none;outline-style:none}.content-sheet\:content-sheet-inset-section:where(.content-sheet *){top:calc(var(--spacing)*0);z-index:10;grid-template-columns:repeat(1,minmax(0,1fr));position:-webkit-sticky;position:sticky}.content-sheet\:content-sheet-inset-section:where(.content-sheet *):has(>:nth-child(2):last-child){grid-template-columns:repeat(2,minmax(0,1fr))}.content-sheet\:content-sheet-inset-section:where(.content-sheet *):has(>:nth-child(3):last-child){grid-template-columns:repeat(3,minmax(0,1fr))}.content-sheet\:content-sheet-inset-section:where(.content-sheet *):has(>:nth-child(4):last-child){grid-template-columns:repeat(4,minmax(0,1fr))}.content-sheet\:content-sheet-inset-section:where(.content-sheet *){gap:calc(var(--spacing)*2);background-color:var(--bg-primary);padding-inline:calc(var(--spacing)*6);padding-top:calc(var(--spacing)*4);display:grid}.content-sheet\:before\:mx-6:where(.content-sheet *):before{content:var(--tw-content);margin-inline:calc(var(--spacing)*6)}.content-sheet\:before\:my-3:where(.content-sheet *):before{content:var(--tw-content);margin-block:calc(var(--spacing)*3)}.content-sheet-section\:flex :where([data-content-sheet-section]){display:flex}.content-sheet-section\:scrollable-content-section :where([data-content-sheet-section]){scrollbar-width:none;max-height:calc(100cqh - 18px);overflow-y:auto}.content-sheet-section\:flex-col :where([data-content-sheet-section]){flex-direction:column}.content-sheet-section\:gap-3 :where([data-content-sheet-section]){gap:calc(var(--spacing)*3)}.content-sheet-root\:contents :where([data-content-sheet-root]){display:contents}.\[\&\]\:border-0{border-style:var(--tw-border-style);border-width:0}.\[\&_\#sidebar-follow-up-button\]\:hidden #sidebar-follow-up-button,.\[\&_\#thread-bottom\]\:hidden #thread-bottom{display:none}.\[\&_\*\]\:pointer-events-none *{pointer-events:none}.\[\&_\*\]\:m-0 *{margin:calc(var(--spacing)*0)}.\[\&_\*\]\:box-border *{box-sizing:border-box}.\[\&_\*\]\:max-w-full *{max-width:100%}.\[\&_\*\]\:min-w-0 *{min-width:calc(var(--spacing)*0)}.\[\&_\*\]\:cursor-pointer *{cursor:pointer}.\[\&_\*\]\:p-0 *{padding:calc(var(--spacing)*0)}.\[\&_\.border-token-border-heavy\]\:border-0 .border-token-border-heavy{border-style:var(--tw-border-style);border-width:0}.\[\&_\.border-token-border-heavy\]\:border-transparent .border-token-border-heavy{border-color:#0000}.\[\&_\.cm-activeLineGutter\]\:bg-transparent\! .cm-activeLineGutter,.\[\&_\.cm-gutters\]\:bg-transparent\! .cm-gutters{background-color:#0000!important}.\[\&_\.cm-scroller\]\:\[-ms-overflow-style\:none\] .cm-scroller{-ms-overflow-style:none}.\[\&_\.cm-scroller\]\:\[scrollbar-width\:none\] .cm-scroller{scrollbar-width:none}.\[\&_\.cm-scroller\:\:-webkit-scrollbar\]\:hidden .cm-scroller::-webkit-scrollbar{display:none}.\[\&_\.details-disclosure-content\]\:-translate-y-0\.5 .details-disclosure-content{--tw-translate-y:calc(var(--spacing)*-.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.\[\&_\.details-disclosure-content\]\:opacity-40 .details-disclosure-content{opacity:.4}.\[\&_\.font-semibold\]\:\!font-normal .font-semibold{--tw-font-weight:var(--font-weight-normal)!important;font-weight:var(--font-weight-normal)!important}.\[\&_\.hint-pill\]\:text-black .hint-pill{color:#000}.\[\&_\.hint-pill\]\:underline .hint-pill{-webkit-text-decoration-line:underline;text-decoration-line:underline}.\[\&_\.hint-pill\]\:decoration-black .hint-pill{-webkit-text-decoration-color:#000;text-decoration-color:#000}.\[\&_\.hint-pill\]\:decoration-1 .hint-pill{text-decoration-thickness:1px}.\[\&_\.hint-pill\]\:underline-offset-2 .hint-pill{text-underline-offset:2px}@media (hover:hover){.hover\:\[\&_\.icon\]\:text-token-text-inverted:hover .icon{color:var(--text-inverted)}.hover\:\[\&_\.icon\]\:text-token-text-primary:hover .icon{color:var(--text-primary)}}.\[\&_\.markdown\]\:text-base .markdown{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}@media (hover:hover){.peer-hover\:\[\&_\.prompt-item-divider\]\:opacity-0:is(:where(.peer):hover~*) .prompt-item-divider,.hover\:\[\&_\.prompt-item-divider\]\:opacity-0:hover .prompt-item-divider{opacity:0}}.\[\&_\.sticky\]\:static .sticky{position:static}.\[\&_\.text-base\]\:text-base\! .text-base,.\[\&_\.text-sm\]\:text-base\! .text-sm{font-size:var(--text-base)!important;line-height:var(--tw-leading,var(--text-base--line-height))!important}.\[\&_\[data-component-group\]\]\:mx-0 [data-component-group]{margin-inline:calc(var(--spacing)*0)}.\[\&_\[data-component-group\]\]\:px-0 [data-component-group]{padding-inline:calc(var(--spacing)*0)}@media not all and (min-width:40rem){.max-sm\:\[\&_\[data-composer-footer-label\]\]\:sr-only [data-composer-footer-label]{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}}.\[\&_\[data-debug-panel-section\]\+\[data-debug-panel-section\]\]\:border-t [data-debug-panel-section]+[data-debug-panel-section]{border-top-style:var(--tw-border-style);border-top-width:1px}.\[\&_\[data-debug-panel-section\]\+\[data-debug-panel-section\]\]\:border-token-border-default [data-debug-panel-section]+[data-debug-panel-section]{border-color:var(--border-default)}.\[\&_\[data-rich-entity-image\]_img\]\:object-cover [data-rich-entity-image] img{object-fit:cover}.\[\&_\[data-rich-entity-image\]_img\]\:object-center [data-rich-entity-image] img{object-position:center}.\[\&_\[data-testid\=announcement-tooltip-close-btn\]\]\:hidden [data-testid=announcement-tooltip-close-btn]{display:none}.\[\&_\[role\=\'option\'\]\]\:text-xs [role=option]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.\[\&_div\]\:leading-\[1\.25\] div{--tw-leading:1.25;line-height:1.25}.\[\&_div\]\:leading-tight div{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.\[\&_h2\]\:font-medium h2{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.\[\&_h2\]\:font-semibold h2{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.\[\&_h2\]\:font-semibold\! h2{--tw-font-weight:var(--font-weight-semibold)!important;font-weight:var(--font-weight-semibold)!important}.\[\&_h2\]\:tracking-\[-0\.01em\] h2{--tw-tracking:-.01em;letter-spacing:-.01em}.\[\&_h3\]\:my-4 h3{margin-block:calc(var(--spacing)*4)}.\[\&_h3\]\:font-sans h3{font-family:"ui-sans-serif",-apple-system,"system-ui",Segoe UI,Helvetica,Apple Color Emoji,Arial,"sans-serif",Segoe UI Emoji,Segoe UI Symbol}.\[\&_h3\]\:text-base h3{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.\[\&_h3\]\:font-normal h3{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.\[\&_h4\]\:my-2 h4{margin-block:calc(var(--spacing)*2)}.\[\&_h4\]\:font-sans h4{font-family:"ui-sans-serif",-apple-system,"system-ui",Segoe UI,Helvetica,Apple Color Emoji,Arial,"sans-serif",Segoe UI Emoji,Segoe UI Symbol}.\[\&_h4\]\:text-sm h4{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.\[\&_h4\]\:font-normal h4{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.\[\&_img\]\:h-8 img{height:calc(var(--spacing)*8)}.\[\&_img\]\:max-h-full img{max-height:100%}.\[\&_img\]\:w-8 img{width:calc(var(--spacing)*8)}.\[\&_img\]\:max-w-full img{max-width:100%}.\[\&_img\]\:scale-100 img{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.\[\&_img\]\:transform-gpu img{transform:translateZ(0)var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.\[\&_img\]\:object-contain img{object-fit:contain}.\[\&_img\]\:object-center img{object-position:center}.\[\&_img\]\:transition-transform img{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.\[\&_img\]\:duration-300 img{--tw-duration:.3s;transition-duration:.3s}@media (hover:hover){.group-hover\:\[\&_img\]\:scale-\[1\.025\]:is(:where(.group):hover *) img{scale:1.025}}.\[\&_kbd\]\:text-\[\#007aff\]\! kbd{color:#007aff!important}.\[\&_kbd\]\:text-blue-100\! kbd{color:var(--blue-100)!important}.dark\:\[\&_kbd\]\:text-\[\#4fa6f7\]\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)) kbd{color:#4fa6f7!important}.dark\:\[\&_kbd\]\:text-white\/40\!:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)) kbd{color:#fff6!important;color:lab(100% -.0000298023 .0000119209/.4)!important}.\[\&_ol_li\]\:list-decimal ol li{list-style-type:decimal}.\[\&_p_code\]\:bg-transparent p code{background-color:#0000}.\[\&_p_code\]\:px-0 p code{padding-inline:calc(var(--spacing)*0)}.\[\&_p_code\]\:whitespace-pre p code{white-space:pre}.\[\&_path\]\:stroke-current path{stroke:currentColor}.\[\&_pre\]\:my-0\! pre{margin-block:calc(var(--spacing)*0)!important}.\[\&_pre\]\:\[-ms-overflow-style\:none\] pre{-ms-overflow-style:none}.\[\&_pre\]\:\[scrollbar-width\:none\] pre{scrollbar-width:none}.\[\&_pre\:\:-webkit-scrollbar\]\:hidden pre::-webkit-scrollbar{display:none}.\[\&_pre\>div\]\:my-0\! pre>div{margin-block:calc(var(--spacing)*0)!important}.\[\&_span\]\:rounded-\[3px\] span{border-radius:3px}.\[\&_span\]\:bg-\[rgba\(15\,23\,42\,0\.03\)\] span{background-color:#0f172a08}.\[\&_span\]\:shadow-\[inset_0_0_0_1px_rgba\(15\,23\,42\,0\.10\)\] span{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color,#0f172a1a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.\[\&_svg\]\:h-3\.5 svg{height:calc(var(--spacing)*3.5)}.\[\&_svg\]\:h-8 svg{height:calc(var(--spacing)*8)}.\[\&_svg\]\:h-full svg{height:100%}.\[\&_svg\]\:max-h-full svg{max-height:100%}.\[\&_svg\]\:w-3\.5 svg{width:calc(var(--spacing)*3.5)}.\[\&_svg\]\:w-8 svg{width:calc(var(--spacing)*8)}.\[\&_svg\]\:w-full svg{width:100%}.\[\&_svg\]\:max-w-full svg{max-width:100%}.\[\&_svg\]\:object-contain svg{object-fit:contain}.\[\&_svg\]\:object-center svg{object-position:center}.\[\&_thead_th\]\:bg-transparent thead th{background-color:#0000}.\[\&_thead_th\]\:pt-2 thead th{padding-top:calc(var(--spacing)*2)}.\[\&_thead_th\]\:pb-3 thead th{padding-bottom:calc(var(--spacing)*3)}.\[\&_thead_th\:nth-child\(1\)\]\:w-\[10\%\] thead th:first-child{width:10%}.\[\&_thead_th\:nth-child\(2\)\]\:w-\[20\%\] thead th:nth-child(2),.\[\&_thead_th\:nth-child\(3\)\]\:w-\[20\%\] thead th:nth-child(3),.\[\&_thead_th\:nth-child\(4\)\]\:w-\[20\%\] thead th:nth-child(4){width:20%}.\[\&_thead_th\:nth-child\(5\)\]\:w-\[30\%\] thead th:nth-child(5){width:30%}.\[\&_tr\:last-child\]\:border-b-0 tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.\[\&_ul_li\]\:list-disc ul li{list-style-type:disc}.\[\&_video\]\:transform-gpu video{transform:translateZ(0)var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.\[\&_video\]\:transition-transform video{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.\[\&_video\]\:duration-200 video{--tw-duration:.2s;transition-duration:.2s}@media (hover:hover){.group-hover\:\[\&_video\]\:scale-\[1\.025\]:is(:where(.group):hover *) video{scale:1.025}}.\[\&\&\]\:text-black.\[\&\&\]\:text-black{color:#000}.\[\&\&\]\:underline.\[\&\&\]\:underline{-webkit-text-decoration-line:underline;text-decoration-line:underline}@media (hover:hover){.\[\&\&\]\:hover\:text-black.\[\&\&\]\:hover\:text-black:hover{color:#000}}.dark\:\[\&\&\]\:text-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)).dark\:\[\&\&\]\:text-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){color:#fff}@media (hover:hover){.dark\:\[\&\&\]\:hover\:text-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)).dark\:\[\&\&\]\:hover\:text-white:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{color:#fff}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none}.\[\&\:\:-webkit-inner-spin-button\]\:appearance-none::-webkit-inner-spin-button{-webkit-appearance:none;appearance:none}.\[\&\:\:-webkit-outer-spin-button\]\:appearance-none::-webkit-outer-spin-button{-webkit-appearance:none;appearance:none}.\[\&\:\:-webkit-scrollbar\]\:hidden::-webkit-scrollbar{display:none}.\[\&\:\:-webkit-search-cancel-button\]\:hidden::-webkit-search-cancel-button{display:none}.\[\&\:\:-webkit-search-cancel-button\]\:appearance-none::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none}.focus\:\[\&\:\:-webkit-search-cancel-button\]\:hidden:focus::-webkit-search-cancel-button{display:none}.\[\&\:\:-webkit-search-decoration\]\:appearance-none::-webkit-search-decoration{-webkit-appearance:none;appearance:none}.\[\&\:\:marker\]\:content-\[\'\'\]::marker{--tw-content:"";content:var(--tw-content)}@media (hover:hover){.can-hover\:\[\&\:has\(\.prompt-card\:focus-within\)_\.prompt-card\:not\(\:focus-within\)\]\:opacity-70:has(.prompt-card:focus-within) .prompt-card:not(:focus-within),.can-hover\:\[\&\:has\(\.prompt-card\:hover\)_\.prompt-card\:not\(\:hover\)\]\:opacity-70:has(.prompt-card:hover) .prompt-card:not(:hover){opacity:.7}}.\[\&\:has\(\>_\[data-value-prop\]\)\]\:gap-6:has(>[data-value-prop]){gap:calc(var(--spacing)*6)}@media (min-width:48rem){.md\:\[\&\:has\(\>_\[data-value-prop\]\)\]\:col-span-12:has(>[data-value-prop]){grid-column:span 12/span 12}.md\:\[\&\:has\(\>_\[data-value-prop\]\)\]\:col-start-1:has(>[data-value-prop]){grid-column-start:1}.md\:\[\&\:has\(\>_\[data-value-prop\]\)\]\:gap-y-0:has(>[data-value-prop]){row-gap:calc(var(--spacing)*0)}}.\[\&\:has\(\[data-writing-block\]\)\>\*\]\:pointer-events-auto:has([data-writing-block])>*{pointer-events:auto}.\[\:not\(\:has\(div\:not\(\[role\=group\]\)\)\)\]\:hidden:not(:has(div:not([role=group]))){display:none}.\[\:not\(\:has\(img\)\)\]\:px-4:not(:has(img)){padding-inline:calc(var(--spacing)*4)}.\[\:not\(\:has\(img\)\)\]\:py-3:not(:has(img)){padding-block:calc(var(--spacing)*3)}:is(.\*\*\:\[p\]\:text-pretty *):is(p){text-wrap:pretty}.\[\&\:not\(\:first-child\)\]\:mt-2:not(:first-child){margin-top:calc(var(--spacing)*2)}.\[\&\:not\(\:first-child\)\]\:mt-4:not(:first-child){margin-top:calc(var(--spacing)*4)}.\[\&\:not\(\:first-child\)\]\:hidden:not(:first-child){display:none}.\[\&\:not\(\:first-child\)\]\:pt-46:not(:first-child){padding-top:calc(var(--spacing)*46)}@media (min-width:40rem){.\[\&\:not\(\:first-child\)\]\:sm\:pt-20:not(:first-child){padding-top:calc(var(--spacing)*20)}}@media (min-width:48rem){.\[\&\:not\(\:first-child\)\]\:md\:block:not(:first-child){display:block}}.\[\&\:not\(\:has\(strong\)\)\]\:mb-\[18px\]:not(:has(strong)){margin-bottom:18px}.\[\&\:not\(\:last-child\)\]\:mb-\[1\.1em\]:not(:last-child){margin-bottom:1.1em}.\[\&\:nth-child\(1_of_\:has\(div\:not\(\[role\=group\]\)\)\)\]\:before\:hidden:nth-child(1 of :has(div:not([role=group]))):before{content:var(--tw-content);display:none}.\[\&\>\*\]\:pointer-events-none>*{pointer-events:none}.\[\&\>\*\]\:col-start-1>*{grid-column-start:1}.\[\&\>\*\]\:row-start-1>*{grid-row-start:1}.\[\&\>\*\]\:m-0>*{margin:calc(var(--spacing)*0)}@media not all and (min-width:40rem){.max-sm\:\[\&\>\*\:nth-child\(4\)\]\:hidden>:nth-child(4){display:none}}@container images-promo-banner (width>=32rem){.\@lg\/images-promo-banner\:\[\&\>\*\:nth-child\(n\+3\)\]\:hidden>:nth-child(n+3){display:none}}@container images-promo-banner (width>=42rem){.\@2xl\/images-promo-banner\:\[\&\>\*\:nth-child\(n\+3\)\]\:flex>:nth-child(n+3){display:flex}.\@2xl\/images-promo-banner\:\[\&\>\*\:nth-child\(n\+5\)\]\:hidden>:nth-child(n+5){display:none}}.last\:\[\&\>\.mobile-divider\]\:hidden:last-child>.mobile-divider{display:none}.\[\&\>\:first-child\]\:mt-0>:first-child{margin-top:calc(var(--spacing)*0)}.\[\&\>\:last-child\]\:mb-0>:last-child{margin-bottom:calc(var(--spacing)*0)}.\[\&\>\[data-debug-panel-section\]\+\[data-debug-panel-section\]\]\:border-t>[data-debug-panel-section]+[data-debug-panel-section]{border-top-style:var(--tw-border-style);border-top-width:1px}.\[\&\>div\]\:h-6>div{height:calc(var(--spacing)*6)}.\[\&\>div\]\:w-6>div{width:calc(var(--spacing)*6)}.\[\&\>div\]\:w-fit>div{width:-webkit-fit-content;width:fit-content}.\[\&\>div\]\:min-w-\[unset\]>div{min-width:unset}.\[\&\>div\]\:items-center>div{align-items:center}.\[\&\>div\]\:justify-center>div{justify-content:center}.\[\&\>div\]\:\!rounded-md>div{border-radius:var(--radius-md)!important}.\[\&\>div\]\:\!border-token-border-heavy>div{border-color:var(--border-heavy)!important}.\[\&\>div\]\:\!p-6>div{padding:calc(var(--spacing)*6)!important}.\[\&\>div\]\:text-center>div{text-align:center}.\[\&\>div\]\:\!shadow-none>div{--tw-shadow:0 0 transparent!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.\[\&\>div\]\:\!ring-0>div{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor)!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}@media (min-width:48rem){.md\:\[\&\>div\]\:\!p-7>div{padding:calc(var(--spacing)*7)!important}}.\[\&\>div\:last-child\]\:border-e>div:last-child:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:1px}.\[\&\>div\:last-child\]\:border-e>div:last-child:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:1px}.\[\&\>div\:last-child\]\:border-b>div:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&\>div\:last-child\]\:border-gray-200>div:last-child{border-color:var(--gray-200)}.\[\&\>div\:last-child\]\:\!bg-white>div:last-child{background-color:#fff!important}.dark\:\[\&\>div\:last-child\]\:border-gray-700:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))>div:last-child{border-color:var(--gray-700)}.dark\:\[\&\>div\:last-child\]\:\!bg-gray-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))>div:last-child{background-color:var(--gray-900)!important}.\[\&\>div\:nth-child\(2\)\]\:overflow-y-hidden>div:nth-child(2){overflow-y:hidden}.\[\&\>div\>div\]\:h-6>div>div{height:calc(var(--spacing)*6)}.\[\&\>div\>div\]\:w-6>div>div{width:calc(var(--spacing)*6)}.\[\&\>div\>div\:first-child\]\:pl-1>div>div:first-child{padding-left:calc(var(--spacing)*1)}@media (min-width:48rem){.md\:\[\&\>div\>div\:first-child\]\:pl-2>div>div:first-child{padding-left:calc(var(--spacing)*2)}}.\[\&\>div\>div\:nth-child\(2\)\]\:\!mt-10>div>div:nth-child(2){margin-top:calc(var(--spacing)*10)!important}@media (min-width:48rem){.md\:\[\&\>div\>div\:nth-child\(2\)\]\:\!mt-11>div>div:nth-child(2){margin-top:calc(var(--spacing)*11)!important}}.\[\&\>div\>div\:nth-child\(3\)\]\:\!mt-8>div>div:nth-child(3){margin-top:calc(var(--spacing)*8)!important}.\[\&\>div\>div\:nth-child\(3\)\]\:\!gap-0>div>div:nth-child(3){gap:calc(var(--spacing)*0)!important}.\[\&\>div\>div\:nth-child\(3\)\]\:\!pt-0>div>div:nth-child(3){padding-top:calc(var(--spacing)*0)!important}@media (min-width:48rem){.md\:\[\&\>div\>div\:nth-child\(3\)\]\:\!mt-9>div>div:nth-child(3){margin-top:calc(var(--spacing)*9)!important}}.\[\&\>h1\]\:my-0>h1{margin-block:calc(var(--spacing)*0)}.\[\&\>h1\]\:ms-1>h1:dir(ltr){margin-left:calc(var(--spacing)*1)}.\[\&\>h1\]\:ms-1>h1:dir(rtl){margin-right:calc(var(--spacing)*1)}.\[\&\>h1\]\:box-trim-0\.25>h1{text-box:trim-both cap alphabetic}@supports not (text-box:trim-both cap alphabetic){.\[\&\>h1\]\:box-trim-0\.25>h1:before{content:"";margin-bottom:calc(.25cap - .25lh);display:table}.\[\&\>h1\]\:box-trim-0\.25>h1:after{content:"";margin-top:calc(.75cap - .75lh);display:table}}.\[\&\>h1\]\:inline>h1{display:inline}.\[\&\>h2\]\:my-0>h2{margin-block:calc(var(--spacing)*0)}.\[\&\>h2\]\:ms-1>h2:dir(ltr){margin-left:calc(var(--spacing)*1)}.\[\&\>h2\]\:ms-1>h2:dir(rtl){margin-right:calc(var(--spacing)*1)}.\[\&\>h2\]\:box-trim-0\.25>h2{text-box:trim-both cap alphabetic}@supports not (text-box:trim-both cap alphabetic){.\[\&\>h2\]\:box-trim-0\.25>h2:before{content:"";margin-bottom:calc(.25cap - .25lh);display:table}.\[\&\>h2\]\:box-trim-0\.25>h2:after{content:"";margin-top:calc(.75cap - .75lh);display:table}}.\[\&\>h2\]\:inline>h2{display:inline}.\[\&\>h3\]\:my-0>h3{margin-block:calc(var(--spacing)*0)}.\[\&\>h3\]\:ms-1>h3:dir(ltr){margin-left:calc(var(--spacing)*1)}.\[\&\>h3\]\:ms-1>h3:dir(rtl){margin-right:calc(var(--spacing)*1)}.\[\&\>h3\]\:box-trim-0\.25>h3{text-box:trim-both cap alphabetic}@supports not (text-box:trim-both cap alphabetic){.\[\&\>h3\]\:box-trim-0\.25>h3:before{content:"";margin-bottom:calc(.25cap - .25lh);display:table}.\[\&\>h3\]\:box-trim-0\.25>h3:after{content:"";margin-top:calc(.75cap - .75lh);display:table}}.\[\&\>h3\]\:inline>h3{display:inline}.\[\&\>h4\]\:my-0>h4{margin-block:calc(var(--spacing)*0)}.\[\&\>h4\]\:ms-1>h4:dir(ltr){margin-left:calc(var(--spacing)*1)}.\[\&\>h4\]\:ms-1>h4:dir(rtl){margin-right:calc(var(--spacing)*1)}.\[\&\>h4\]\:box-trim-0\.25>h4{text-box:trim-both cap alphabetic}@supports not (text-box:trim-both cap alphabetic){.\[\&\>h4\]\:box-trim-0\.25>h4:before{content:"";margin-bottom:calc(.25cap - .25lh);display:table}.\[\&\>h4\]\:box-trim-0\.25>h4:after{content:"";margin-top:calc(.75cap - .75lh);display:table}}.\[\&\>h4\]\:inline>h4{display:inline}.\[\&\>h5\]\:my-0>h5{margin-block:calc(var(--spacing)*0)}.\[\&\>h5\]\:ms-1>h5:dir(ltr){margin-left:calc(var(--spacing)*1)}.\[\&\>h5\]\:ms-1>h5:dir(rtl){margin-right:calc(var(--spacing)*1)}.\[\&\>h5\]\:box-trim-0\.25>h5{text-box:trim-both cap alphabetic}@supports not (text-box:trim-both cap alphabetic){.\[\&\>h5\]\:box-trim-0\.25>h5:before{content:"";margin-bottom:calc(.25cap - .25lh);display:table}.\[\&\>h5\]\:box-trim-0\.25>h5:after{content:"";margin-top:calc(.75cap - .75lh);display:table}}.\[\&\>h5\]\:inline>h5{display:inline}.\[\&\>h6\]\:my-0>h6{margin-block:calc(var(--spacing)*0)}.\[\&\>h6\]\:ms-1>h6:dir(ltr){margin-left:calc(var(--spacing)*1)}.\[\&\>h6\]\:ms-1>h6:dir(rtl){margin-right:calc(var(--spacing)*1)}.\[\&\>h6\]\:box-trim-0\.25>h6{text-box:trim-both cap alphabetic}@supports not (text-box:trim-both cap alphabetic){.\[\&\>h6\]\:box-trim-0\.25>h6:before{content:"";margin-bottom:calc(.25cap - .25lh);display:table}.\[\&\>h6\]\:box-trim-0\.25>h6:after{content:"";margin-top:calc(.75cap - .75lh);display:table}}.\[\&\>h6\]\:inline>h6{display:inline}.\[\&\>option\]\:bg-token-main-surface-primary>option{background-color:var(--main-surface-primary)}.\[\&\>option\]\:text-token-text-primary>option{color:var(--text-primary)}.\[\&\>p\:last-child\]\:mb-0\!>p:last-child{margin-bottom:calc(var(--spacing)*0)!important}.\[\&\>section\]\:pt-14>section{padding-top:calc(var(--spacing)*14)}.\[\&\>section\]\:pb-28>section{padding-bottom:calc(var(--spacing)*28)}@media (min-width:48rem){.md\:\[\&\>section\]\:px-10>section{padding-inline:calc(var(--spacing)*10)}.md\:\[\&\>section\]\:pt-16>section{padding-top:calc(var(--spacing)*16)}.md\:\[\&\>section\]\:pb-32>section{padding-bottom:calc(var(--spacing)*32)}}@media (min-width:64rem){.lg\:\[\&\>section\]\:px-16>section{padding-inline:calc(var(--spacing)*16)}.lg\:\[\&\>section\]\:pt-20>section{padding-top:calc(var(--spacing)*20)}.lg\:\[\&\>section\]\:pb-36>section{padding-bottom:calc(var(--spacing)*36)}}.\[\&\>section\>div\:last-child\]\:mt-12>section>div:last-child{margin-top:calc(var(--spacing)*12)}.\[\&\>section\>div\:last-child\]\:gap-20>section>div:last-child{gap:calc(var(--spacing)*20)}@media (min-width:48rem){.md\:\[\&\>section\>div\:last-child\]\:mt-16>section>div:last-child{margin-top:calc(var(--spacing)*16)}.md\:\[\&\>section\>div\:last-child\]\:gap-24>section>div:last-child{gap:calc(var(--spacing)*24)}}@media (min-width:64rem){.lg\:\[\&\>section\>div\:last-child\]\:gap-32>section>div:last-child{gap:calc(var(--spacing)*32)}}@media (min-width:48rem){.md\:\[\&\>section\>div\:last-child\>div\>div\:first-child\]\:min-w-0>section>div:last-child>div>div:first-child{min-width:calc(var(--spacing)*0)}.md\:\[\&\>section\>div\:last-child\>div\>div\:first-child\]\:flex-\[0\.9_1_0\%\]>section>div:last-child>div>div:first-child{flex:.9}.md\:\[\&\>section\>div\:last-child\>div\>div\:first-child\]\:basis-0>section>div:last-child>div>div:first-child{flex-basis:calc(var(--spacing)*0)}.md\:\[\&\>section\>div\:last-child\>div\>div\:last-child\]\:min-w-0>section>div:last-child>div>div:last-child{min-width:calc(var(--spacing)*0)}.md\:\[\&\>section\>div\:last-child\>div\>div\:last-child\]\:flex-\[1\.9_1_0\%\]>section>div:last-child>div>div:last-child{flex:1.9}.md\:\[\&\>section\>div\:last-child\>div\>div\:last-child\]\:basis-0>section>div:last-child>div>div:last-child{flex-basis:calc(var(--spacing)*0)}}.\[\&\>span\:last-child\>div\]\:border-e>span:last-child>div:dir(ltr){border-right-style:var(--tw-border-style);border-right-width:1px}.\[\&\>span\:last-child\>div\]\:border-e>span:last-child>div:dir(rtl){border-left-style:var(--tw-border-style);border-left-width:1px}.\[\&\>span\:last-child\>div\]\:border-b>span:last-child>div{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&\>span\:last-child\>div\]\:border-gray-200>span:last-child>div{border-color:var(--gray-200)}.\[\&\>span\:last-child\>div\]\:\!bg-white>span:last-child>div{background-color:#fff!important}.dark\:\[\&\>span\:last-child\>div\]\:border-gray-700:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))>span:last-child>div{border-color:var(--gray-700)}.dark\:\[\&\>span\:last-child\>div\]\:\!bg-gray-900:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))>span:last-child>div{background-color:var(--gray-900)!important}.\[\&\>svg\]\:h-4>svg{height:calc(var(--spacing)*4)}.\[\&\>svg\]\:w-4>svg{width:calc(var(--spacing)*4)}.\[\&\>svg\]\:flex-shrink-0>svg{flex-shrink:0}.\[\&\>td\]\:py-2>td{padding-block:calc(var(--spacing)*2)}.\[\&\>textarea\]\:min-h-\[480px\]>textarea{min-height:480px}.\[\&\[open\]_\.details-disclosure-content\]\:translate-y-0[open] .details-disclosure-content{--tw-translate-y:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.\[\&\[open\]_\.details-disclosure-content\]\:opacity-100[open] .details-disclosure-content{opacity:1}.\[\&\[open\]\:\:details-content\]\:grid-rows-\[1fr\][open]::details-content{grid-template-rows:1fr}.text-message+.\[\.text-message\+\&\]\:mt-1{margin-top:calc(var(--spacing)*1)}.text-message+.\[\.text-message\+\&\]\:mt-5{margin-top:calc(var(--spacing)*5)}@media (max-height:550px){.\[\@media\(max-height\:550px\)\]\:hidden{display:none}}@media (min-width:1560px){.\[\@media\(min-width\:1560px\)\]\:top-0{top:calc(var(--spacing)*0)}}@media (min-width:450px){.\[\@media\(min-width\:450px\)\]\:inline{display:inline}.\[\@media\(min-width\:450px\)\]\:w-\[380px\]{width:380px}.\[\@media\(min-width\:450px\)\]\:w-\[450px\]{width:450px}.\[\@media\(min-width\:450px\)\]\:w-\[520px\]{width:520px}.\[\@media\(min-width\:450px\)\]\:gap-3{gap:calc(var(--spacing)*3)}.\[\@media\(min-width\:450px\)\]\:px-0{padding-inline:calc(var(--spacing)*0)}.\[\@media\(min-width\:450px\)\]\:px-5{padding-inline:calc(var(--spacing)*5)}.\[\@media\(min-width\:450px\)\]\:pt-\[12vh\]{padding-top:12vh}.\[\@media\(min-width\:450px\)\]\:pt-\[15vh\]{padding-top:15vh}.\[\@media\(min-width\:450px\)\]\:leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}}[data-chat-theme=black]>.\[\[data-chat-theme\=black\]\>\&\]\:bg-black{background-color:#000}[data-chat-theme=default]>.\[\[data-chat-theme\=default\]\>\&\]\:bg-gray-400{background-color:var(--gray-400)}[data-chat-theme=default]>.\[\[data-chat-theme\=default\]\>\&\]\:dark\:bg-gray-500:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--gray-500)}[data-collapse-labels] .\[\[data-collapse-labels\]_\&\]\:sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}[data-collapse-labels] .\[\[data-collapse-labels\]_\&\]\:me-1\.5:dir(ltr){margin-right:calc(var(--spacing)*1.5)}[data-collapse-labels] .\[\[data-collapse-labels\]_\&\]\:me-1\.5:dir(rtl){margin-left:calc(var(--spacing)*1.5)}[data-collapse-labels] .\[\[data-collapse-labels\]_\&\]\:inline-flex{display:inline-flex}tr:last-child .\[tr\:last-child_\&\]\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}tr[data-disabled=true] .\[tr\[data-disabled\=true\]_\&\]\:opacity-50{opacity:.5}:lang(vi) .font-oai{font-family:sans-serif}.mkt-no-scrollbar{-ms-overflow-style:none;scrollbar-width:none;-webkit-overflow-scrolling:touch}.mkt-no-scrollbar::-webkit-scrollbar{display:none}.btn:where(.-mkt){font-size:.875rem;line-height:var(--tw-leading,1.435rem);letter-spacing:var(--tw-tracking,-.01em);font-weight:var(--tw-font-weight,400);transition-property:color,background-color,border-color,outline-color,-webkit-text-decoration-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.btn-small:where(.-mkt){font-size:.625rem;line-height:var(--tw-leading,.825rem);letter-spacing:var(--tw-tracking,clamp(-.01em,calc(-.01em + .01*((90rem - 100vw)/66.5625)),0em));font-weight:var(--tw-font-weight,400)}.btn-primary-inverse:where(.-mkt){background-color:#0000000a;background-color:lab(0% 0 0/.04)}@media (hover:hover){.btn-primary-inverse:where(.-mkt):hover{background-color:#0000001f;background-color:lab(0% 0 0/.12)}}.btn-primary-inverse:where(.-mkt):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:#ffffff0a;background-color:lab(100% -.0000298023 .0000119209/.04)}@media (hover:hover){.btn-primary-inverse:where(.-mkt):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):hover{background-color:#ffffff1f;background-color:lab(100% -.0000298023 .0000119209/.12)}}.prose:where(.-mkt) :not(:where([class~=not-prose],[class~=not-prose] *)):where(p,table,ul,ol){margin-block:calc(var(--spacing)*0)}.prose:where(.-mkt) :not(:where([class~=not-prose],[class~=not-prose] *)):where(b,strong){--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}@media (pointer:coarse){[data-content-sheet-section]{--text-sm:1rem;--text-sm--line-height:1.25rem;--text-sm--letter-spacing:0;--menu-item-height:44px;--icon-lg-size:22px}[data-content-sheet-section] .__menu-item{padding-inline:calc(var(--spacing)*6)}}.skeleton-child{--skeleton-gradient-from:#e8e8e8;--skeleton-gradient-to:#cdcdcd;--skeleton-gradient-via:#f9f9f9;opacity:var(--skeleton-opacity);will-change:auto}.skeleton-child:is(:where(.group).skeleton *){border-radius:var(--radius-lg);--tw-gradient-position:to right;border-color:#0000!important}@supports (background-image:linear-gradient(in lab, red, red)){.skeleton-child:is(:where(.group).skeleton *){--tw-gradient-position:to right in oklab}}.skeleton-child:is(:where(.group).skeleton *){background-image:linear-gradient(var(--tw-gradient-stops));--tw-gradient-from:var(--skeleton-gradient-from);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position));--tw-gradient-via:var(--skeleton-gradient-via);--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-to:var(--skeleton-gradient-to);-webkit-box-decoration-break:clone;box-decoration-break:clone;--tw-leading:calc(var(--spacing)*7);line-height:calc(var(--spacing)*7);color:#0000;background-size:300%;animation-direction:alternate-reverse!important}@media (prefers-reduced-motion:no-preference){.skeleton-child:is(:where(.group).skeleton *){animation:2s ease-in-out infinite shimmer-skeleton}}.dark .skeleton-child{--skeleton-gradient-from:#303030;--skeleton-gradient-to:#414141;--skeleton-gradient-via:#5d5d5d}.skeleton-child.skeleton-translucent{--skeleton-gradient-from:#00000014;--skeleton-gradient-to:#0000001a;--skeleton-gradient-via:#0000000a;background-color:#0000}.dark .skeleton-child.skeleton-translucent{--skeleton-gradient-from:#ffffff26;--skeleton-gradient-to:#fff3;--skeleton-gradient-via:#ffffff1a;background-color:#0000}.empty-skeleton{width:100%;height:100%;display:flex}@media (hover:hover){.dropdown-btn:where(:not(:disabled,:active)):hover:before{content:var(--tw-content);background-color:var(--interactive-bg-secondary-hover)}}.dropdown-btn{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--text-primary);z-index:0;height:calc(var(--spacing)*9);min-width:calc(var(--spacing)*9);justify-content:center;place-self:center;align-items:center;display:flex;position:relative}.dropdown-btn:enabled{cursor:pointer}.dropdown-btn:disabled{cursor:not-allowed}.dropdown-btn{border-style:var(--tw-border-style);border-width:1px;border-color:#0000}.dropdown-btn[data-is-selected]{color:var(--interactive-label-accent-default)}.dropdown-btn[data-is-selected]:active:before{content:var(--tw-content);background-color:var(--interactive-bg-accent-muted-press)}.dropdown-btn{white-space:nowrap;-webkit-user-select:none;user-select:none}:is(.dropdown-btn>*){pointer-events:none}.dropdown-btn:focus{--tw-outline-style:none;outline-style:none}.dropdown-btn:disabled{opacity:.3}.dropdown-btn:is(html[data-focus-mode=keyboard] :focus-visible):before{content:var(--tw-content);outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;content:var(--tw-content);outline-style:solid;outline-color:var(--interactive-border-tertiary-inactive)}.dropdown-btn[data-subtle-focus]:is(html[data-focus-mode=keyboard] :focus-visible):before{content:var(--tw-content);outline-style:var(--tw-outline-style);content:var(--tw-content);outline-offset:calc(var(--focus-outline-margin,2px)*-1);outline-width:1px}.dropdown-btn:active:before,.dropdown-btn:not([data-no-open-state])[data-is-open]:before,.dropdown-btn:not([data-no-open-state])[data-state=open]:before{content:var(--tw-content);background-color:var(--interactive-bg-secondary-press)}.dropdown-btn:before{inset:calc(var(--spacing)*0);z-index:-1;--tw-content:"";content:var(--tw-content);border-radius:3.40282e38px;width:100%;height:100%;display:block;position:absolute}@media (hover:hover){.composer-btn:where(:not(:disabled,:active)):hover{background-color:var(--interactive-bg-secondary-hover)}}@media (hover:none){.composer-btn:where(:not(:disabled,:active)){background-color:var(--interactive-bg-secondary-hover)}}.composer-btn{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--text-primary);z-index:0;height:calc(var(--spacing)*9);min-width:calc(var(--spacing)*9);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex;position:relative}.composer-btn:enabled{cursor:pointer}.composer-btn:disabled{cursor:not-allowed}.composer-btn{white-space:nowrap;-webkit-user-select:none;user-select:none}:is(.composer-btn>*){pointer-events:none}.composer-btn:disabled{opacity:.3}.composer-btn:is(html[data-focus-mode=keyboard] :focus-visible){outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}.composer-btn:active,.composer-btn[data-is-open],.composer-btn[data-state=open]{background-color:var(--interactive-bg-secondary-press)}.composer-btn:before{content:var(--tw-content);content:var(--tw-content);inset:calc(var(--spacing)*0);content:var(--tw-content);content:var(--tw-content);content:var(--tw-content);--tw-translate-x:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y);content:var(--tw-content);--tw-translate-y:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y);content:var(--tw-content);transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,);position:absolute;top:50%;left:50%}.composer-btn--desktopSidebar{border-radius:8px}.composer-submit-btn{border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.composer-submit-btn:disabled{color:#f4f4f4}@media (hover:hover){.composer-submit-btn:disabled:hover{opacity:1}}.composer-submit-btn:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):disabled{background-color:var(--text-quaternary);color:var(--main-surface-secondary)}.composer-submit-btn:is(html[data-focus-mode=keyboard] :focus-visible){outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}.composer-submit-btn:disabled{opacity:.35}@media (hover:hover){.composer-submit-btn:disabled:hover{opacity:.35}}.composer-submit-btn[data-in-menu]{color:#fff;background-color:#000}.composer-submit-btn[data-in-menu]:disabled{color:var(--text-tertiary);background-color:#00000014}.composer-submit-btn:where(.dark,.dark *):not(:where(.dark .light,.dark .light *))[data-in-menu]{color:#000;background-color:#fff}.composer-submit-btn[data-in-menu]:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)):disabled{background-color:#ffffff29}.menu-item-input-stop-btn{border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.menu-item-input-stop-btn:is(html[data-focus-mode=keyboard] :focus-visible){outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}.__composer-pill{height:calc(var(--spacing)*9);align-items:center;gap:calc(var(--spacing)*1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--interactive-label-accent-default);--tw-outline-style:none;-webkit-user-select:none;user-select:none;border-radius:3.40282e38px;outline-style:none;display:flex;position:relative}.__composer-pill:dir(ltr){padding-left:calc(var(--spacing)*2);padding-right:calc(var(--spacing)*3)}.__composer-pill:dir(rtl){padding-right:calc(var(--spacing)*2);padding-left:calc(var(--spacing)*3)}.__composer-pill:is(html[data-focus-mode=keyboard] :focus-visible){outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}button.__composer-pill{cursor:pointer}.__composer-pill[data-state=open]{background-color:var(--interactive-bg-accent-muted-hover)}@media (hover:none){.__composer-pill{background-color:var(--interactive-bg-accent-muted-hover)}}@media (hover:hover){button.__composer-pill:hover{background-color:var(--interactive-bg-accent-muted-hover)}}button.__composer-pill:active{background-color:var(--interactive-bg-accent-muted-press)}.__composer-pill:before{content:var(--tw-content);content:var(--tw-content);inset:calc(var(--spacing)*0);position:absolute}@media (hover:hover){.__composer-pill:is(:where(.group):hover *):not(:hover){background-color:var(--interactive-bg-accent-muted-context)}@media not all and (hover:hover){.__composer-pill:is(:where(.group):hover *){background-color:var(--interactive-bg-accent-muted-context)}}.__composer-pill:is(:where(.group):has(:is(html[data-focus-mode=keyboard] :focus-visible)) *):not(:focus),button.__composer-pill:has(.__composer-pill-remove):hover{background-color:var(--interactive-bg-accent-muted-context)}}button.__composer-pill:has(.__composer-pill-remove):is(html[data-focus-mode=keyboard] :focus-visible){background-color:var(--interactive-bg-accent-muted-context)}@media (hover:hover){button.__composer-pill:has(.__composer-pill-remove):is(html[data-focus-mode=keyboard] :focus-visible){--tw-outline-style:none;outline-style:none}}@media (hover:none){.__composer-pill:dir(ltr){padding-left:calc(var(--spacing)*2.5);padding-right:calc(var(--spacing)*3.5)}.__composer-pill:dir(rtl){padding-right:calc(var(--spacing)*2.5);padding-left:calc(var(--spacing)*3.5)}.__composer-pill-composite>.__composer-pill:dir(ltr){border-top-right-radius:0;border-bottom-right-radius:0}.__composer-pill-composite>.__composer-pill:dir(rtl){border-top-left-radius:0;border-bottom-left-radius:0}}.__composer-pill-composite{display:flex}.__composer-pill-icon{height:calc(var(--spacing)*5);width:calc(var(--spacing)*5);flex-grow:0;flex-shrink:0;justify-content:center;align-items:center;display:flex}@media (hover:hover){.__composer-pill-icon:is(:where(.group):hover *),.__composer-pill-icon:is(:where(.group):has(:is(html[data-focus-mode=keyboard] :focus-visible)) *):is(:where(.group\/pill):not(:focus) *),.__composer-pill-icon:is(:where(.group)[data-state=open] *),button:has(.__composer-pill-remove)>.__composer-pill-icon:is(:where(.group):is(html[data-focus-mode=keyboard] :focus-visible) *){visibility:hidden}}.__composer-pill-remove{color:var(--interactive-label-accent-default);--tw-outline-style:none;outline-style:none;justify-content:center;align-items:center;display:flex}.__composer-pill-remove:is(html[data-focus-mode=keyboard] :focus-visible){outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}@media (hover:hover){.__composer-pill-remove{top:calc(var(--spacing)*2);z-index:10;height:calc(var(--spacing)*5);width:calc(var(--spacing)*5);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex;position:absolute}.__composer-pill-remove:dir(ltr){left:calc(var(--spacing)*2)}.__composer-pill-remove:dir(rtl){right:calc(var(--spacing)*2)}.__composer-pill-remove:not(:is(html[data-focus-mode=keyboard] :focus-visible)){opacity:0}@media (hover:hover){.__composer-pill-remove:is(:where(.group):hover *){opacity:1!important}}.__composer-pill-remove:is(:where(.group):has([data-state=open]) *),.__composer-pill-remove:is(:where(.group):is(html[data-focus-mode=keyboard] :focus-visible) *){opacity:1!important}.__composer-pill-remove:is(:where(.group):is(html[data-focus-mode=keyboard] :focus-visible) *),.__composer-pill-remove:is(html[data-focus-mode=keyboard] :focus-visible){outline-style:var(--tw-outline-style);outline-offset:2.5px;outline-width:1.5px;outline-color:var(--text-primary);--tw-outline-style:solid;outline-style:solid}.__composer-pill-remove:before{content:var(--tw-content);content:var(--tw-content);inset:calc(var(--spacing)*-2);content:var(--tw-content);content:var(--tw-content);background-color:#0000;position:absolute}.__composer-pill-remove:dir(ltr):before{right:calc(var(--spacing)*-1)}.__composer-pill-remove:dir(rtl):before{left:calc(var(--spacing)*-1)}.__composer-pill-remove{background-color:var(--bg-primary)}.__composer-pill-remove:where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--bg-secondary)}@media (hover:hover){.__composer-pill-remove:hover,div.__composer-pill-remove:is(:where(.group):hover *){background-color:var(--interactive-bg-accent-hover)}:is(.__composer-pill-remove:hover,div.__composer-pill-remove:is(:where(.group):hover *)):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--interactive-bg-accent-muted-hover)}}.__composer-pill-remove:active,div.__composer-pill-remove:is(:where(.group):active *){background-color:var(--interactive-bg-accent-press)}:is(.__composer-pill-remove:active,div.__composer-pill-remove:is(:where(.group):active *)):where(.dark,.dark *):not(:where(.dark .light,.dark .light *)){background-color:var(--interactive-bg-accent-muted-press)}}@media (hover:none){.__composer-pill-remove{order:1}.__composer-pill-remove:dir(ltr){margin-right:calc(var(--spacing)*-1)}.__composer-pill-remove:dir(rtl){margin-left:calc(var(--spacing)*-1)}.__composer-pill-remove:focus{z-index:10}.__composer-pill-remove:before{content:var(--tw-content);content:var(--tw-content);inset:calc(var(--spacing)*0);content:var(--tw-content);position:absolute}.__composer-pill-remove:dir(ltr):before{left:-1px}.__composer-pill-remove:dir(rtl):before{right:-1px}.__composer-pill-composite>.__composer-pill-remove{background-color:var(--interactive-bg-accent-muted-hover)}.__composer-pill-composite>.__composer-pill-remove:dir(ltr){margin-left:1px;margin-right:calc(var(--spacing)*0);padding-left:calc(var(--spacing)*1.5);padding-right:calc(var(--spacing)*2.5);border-top-right-radius:3.40282e38px;border-bottom-right-radius:3.40282e38px}.__composer-pill-composite>.__composer-pill-remove:dir(rtl){margin-right:1px;margin-left:calc(var(--spacing)*0);padding-right:calc(var(--spacing)*1.5);padding-left:calc(var(--spacing)*2.5);border-top-left-radius:3.40282e38px;border-bottom-left-radius:3.40282e38px}.__composer-pill-composite>.__composer-pill-remove:active{background-color:var(--interactive-bg-accent-muted-press)}}.prose :where(h1,h2,h3,h4,h5,h6) strong:not(:where([class~=not-prose] *)){font-weight:inherit}.with-spinner{-webkit-appearance:auto;appearance:auto;-moz-appearance:number-input}.with-spinner::-webkit-inner-spin-button{-webkit-appearance:auto;appearance:auto;margin:initial}.with-spinner::-webkit-outer-spin-button{-webkit-appearance:auto;appearance:auto;margin:initial}.overflow-auto>*,.overflow-scroll>*,.overflow-x-auto>*,.overflow-y-auto>*{scrollbar-color:auto}.overflow-auto,.overflow-scroll,.overflow-x-auto,.overflow-y-auto,.overflow-x-scroll,.overflow-y-scroll{scrollbar-color:var(--scrollbar-color)transparent}.overflow-auto:hover,.overflow-scroll:hover,.overflow-x-auto:hover,.overflow-y-auto:hover{scrollbar-color:var(--scrollbar-color-hover)transparent}.content-fade-top:after{--offset-y:var(--offset-y-default,25px);--fade-start-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.content-fade-top:after{--fade-start-color:color-mix(in lch longer hue,var(--bg-primary),transparent 100%)}}.content-fade-top:after{--fade-end-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.content-fade-top:after{--fade-end-color:color-mix(in lch longer hue,var(--bg-primary),transparent 0%)}}.content-fade-top:after{background:linear-gradient(to top,transparent,var(--bg-primary)),linear-gradient(to top,transparent var(--offset-y),var(--bg-primary)var(--offset-y));background:linear-gradient(to top,var(--fade-start-color),var(--fade-end-color)),linear-gradient(to top,transparent var(--offset-y),var(--bg-primary)var(--offset-y));background-size:100% var(--offset-y),100% 100%;content:"";pointer-events:none;inset:var(--content-fade-top,0)0 0 0;z-index:-1;bottom:var(--offset-y-bottom,calc(var(--offset-y)*-1));background-position:bottom,top;background-repeat:no-repeat;position:absolute}.content-fade.single-line:after{--single-line-fade-height:var(--content-fade-height,28px);background:linear-gradient(to bottom,transparent,var(--bg-primary)),linear-gradient(to bottom,transparent var(--single-line-fade-height),var(--bg-primary)var(--single-line-fade-height));background:linear-gradient(to bottom,var(--fade-start-color),var(--fade-end-color)),linear-gradient(to bottom,transparent var(--single-line-fade-height),var(--bg-primary)var(--single-line-fade-height));background-size:100% var(--single-line-fade-height),100% 100%}.content-fade:after{--content-fade-distance:var(--content-fade-height,55px);--fade-start-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.content-fade:after{--fade-start-color:color-mix(in lch longer hue,var(--bg-primary),transparent 100%)}}.content-fade:after{--fade-end-color:var(--bg-primary)}@supports (color:color-mix(in lab, red, red)){.content-fade:after{--fade-end-color:color-mix(in lch longer hue,var(--bg-primary),transparent 0%)}}.content-fade:after{background:linear-gradient(to bottom,transparent,var(--bg-primary)),linear-gradient(to bottom,transparent var(--content-fade-distance),var(--bg-primary)var(--content-fade-distance));background:linear-gradient(to bottom,var(--fade-start-color),var(--fade-end-color)),linear-gradient(to bottom,transparent var(--content-fade-distance),var(--bg-primary)var(--content-fade-distance));background-size:100% var(--content-fade-distance),100% 100%;content:"";pointer-events:none;inset:var(--content-fade-top,0)0 0 0;z-index:-1;background-position:top,bottom;background-repeat:no-repeat;position:absolute}}[inert]{pointer-events:none;cursor:inherit}[inert],[inert] *{-webkit-user-select:none;user-select:none}html,.light,.dark .light{--bg-primary:#fff;--bg-primary-inverted:#000;--bg-secondary:#e8e8e8;--bg-tertiary:#f3f3f3;--bg-scrim:#0d0d0d80;--bg-elevated-primary:#fff;--bg-elevated-secondary:#f9f9f9;--bg-accent-static:var(--blue-400);--bg-status-warning:var(--orange-25);--bg-status-error:var(--red-25);--border-default:#0d0d0d1a;--border-heavy:#0d0d0d26;--border-light:#0d0d0d0d;--border-status-warning:var(--orange-50);--border-status-error:var(--red-50);--text-primary:#0d0d0d;--text-secondary:#5d5d5d;--text-tertiary:#8f8f8f;--text-inverted:#fff;--text-inverted-static:#fff;--text-accent:var(--blue-200);--text-status-warning:var(--orange-500);--text-status-error:var(--red-500);--icon-primary:#0d0d0d;--icon-secondary:#5d5d5d;--icon-tertiary:#8f8f8f;--icon-inverted:#fff;--icon-inverted-static:#fff;--icon-accent:var(--blue-400);--icon-status-warning:var(--orange-500);--icon-status-error:var(--red-500);--interactive-bg-primary-default:#0d0d0d;--interactive-bg-primary-hover:#0d0d0dcc;--interactive-bg-primary-press:#0d0d0de5;--interactive-bg-primary-inactive:#0d0d0d;--interactive-bg-primary-selected:#0d0d0d;--interactive-bg-secondary-default:#0d0d0d00;--interactive-bg-secondary-hover:#0d0d0d05;--interactive-bg-secondary-press:#0d0d0d0d;--interactive-bg-secondary-inactive:#0d0d0d00;--interactive-bg-secondary-selected:#0d0d0d0d;--interactive-bg-tertiary-default:#fff;--interactive-bg-tertiary-hover:#f9f9f9;--interactive-bg-tertiary-press:#f3f3f3;--interactive-bg-tertiary-inactive:#fff;--interactive-bg-tertiary-selected:#fff;--interactive-bg-accent-default:var(--blue-50);--interactive-bg-accent-hover:var(--blue-75);--interactive-bg-accent-muted-hover:#ebf4ff;--interactive-bg-accent-muted-context:#ebf4ff80;--interactive-bg-accent-press:var(--blue-100);--interactive-bg-accent-muted-press:#e0efff;--interactive-bg-accent-inactive:var(--blue-50);--interactive-bg-danger-primary-default:var(--red-500);--interactive-bg-danger-primary-hover:var(--red-400);--interactive-bg-danger-primary-press:var(--red-600);--interactive-bg-danger-primary-inactive:var(--red-500);--interactive-bg-danger-secondary-default:#0d0d0d00;--interactive-bg-danger-secondary-hover:#0d0d0d00;--interactive-bg-danger-secondary-press:#0d0d0d00;--interactive-bg-danger-secondary-inactive:#0d0d0d00;--interactive-border-focus:#0d0d0d;--interactive-border-secondary-default:#0d0d0d1a;--interactive-border-secondary-hover:#0d0d0d0d;--interactive-border-secondary-press:#0d0d0d0d;--interactive-border-secondary-inactive:#0d0d0d1a;--interactive-border-tertiary-default:#0d0d0d1a;--interactive-border-tertiary-hover:#0d0d0d1a;--interactive-border-tertiary-press:#0d0d0d0d;--interactive-border-tertiary-inactive:#0d0d0d1a;--interactive-border-danger-secondary-default:var(--red-500);--interactive-border-danger-secondary-hover:var(--red-400);--interactive-border-danger-secondary-press:var(--red-600);--interactive-border-danger-secondary-inactive:var(--red-500);--interactive-label-primary-default:#fff;--interactive-label-primary-hover:#fff;--interactive-label-primary-press:#fff;--interactive-label-primary-inactive:#fff;--interactive-label-primary-selected:#fff;--interactive-label-secondary-default:#0d0d0d;--interactive-label-secondary-hover:#0d0d0de5;--interactive-label-secondary-press:#0d0d0dcc;--interactive-label-secondary-inactive:#0d0d0d;--interactive-label-secondary-selected:#0d0d0d;--interactive-label-tertiary-default:#5d5d5d;--interactive-label-tertiary-hover:#5d5d5d;--interactive-label-tertiary-press:#5d5d5d;--interactive-label-tertiary-inactive:#5d5d5d;--interactive-label-tertiary-selected:#5d5d5d;--interactive-label-accent-default:var(--blue-400);--interactive-label-accent-hover:var(--blue-400);--interactive-label-accent-press:var(--blue-400);--interactive-label-accent-inactive:var(--blue-400);--interactive-label-accent-selected:var(--blue-400);--interactive-label-danger-primary-default:#fff;--interactive-label-danger-primary-hover:#fff;--interactive-label-danger-primary-press:#fff;--interactive-label-danger-primary-inactive:#fff;--interactive-label-danger-secondary-default:var(--red-500);--interactive-label-danger-secondary-hover:var(--red-400);--interactive-label-danger-secondary-press:var(--red-600);--interactive-label-danger-secondary-inactive:var(--red-500);--interactive-icon-primary-default:#fff;--interactive-icon-primary-hover:#fff;--interactive-icon-primary-press:#fff;--interactive-icon-primary-selected:#fff;--interactive-icon-primary-inactive:#fff;--interactive-icon-secondary-default:#0d0d0d;--interactive-icon-secondary-hover:#0d0d0de5;--interactive-icon-secondary-press:#0d0d0dcc;--interactive-icon-secondary-selected:#0d0d0d;--interactive-icon-secondary-inactive:#0d0d0d;--interactive-icon-tertiary-default:#5d5d5d;--interactive-icon-tertiary-hover:#5d5d5d;--interactive-icon-tertiary-press:#5d5d5d;--interactive-icon-tertiary-selected:#5d5d5d;--interactive-icon-tertiary-inactive:#5d5d5d;--interactive-icon-accent-default:var(--blue-400);--interactive-icon-accent-hover:var(--blue-400);--interactive-icon-accent-press:var(--blue-400);--interactive-icon-accent-selected:var(--blue-400);--interactive-icon-accent-inactive:var(--blue-400);--interactive-icon-danger-primary-default:#fff;--interactive-icon-danger-primary-hover:#fff;--interactive-icon-danger-primary-press:#fff;--interactive-icon-danger-primary-inactive:#fff;--interactive-icon-danger-secondary-default:var(--red-500);--interactive-icon-danger-secondary-hover:var(--red-400);--interactive-icon-danger-secondary-press:var(--red-600);--interactive-icon-danger-secondary-inactive:var(--red-500);--utility-scrollbar:#0000000a}html[data-contrast=high]:not(.dark){--text-tertiary:#5d5d5d}@media (prefers-contrast:more){html[data-contrast=default]:not(.dark){--text-tertiary:#5d5d5d}}.dark{--bg-primary:#212121;--bg-primary-inverted:#fff;--bg-secondary:#303030;--bg-tertiary:#414141;--bg-scrim:#0d0d0d80;--bg-elevated-primary:#303030;--bg-elevated-secondary:#181818;--bg-accent-static:var(--blue-400);--bg-status-warning:var(--orange-900);--bg-status-error:var(--red-900);--border-default:#ffffff26;--border-heavy:#fff3;--border-light:#ffffff0d;--border-status-warning:var(--orange-900);--border-status-error:var(--red-900);--text-primary:#fff;--text-secondary:#f3f3f3;--text-tertiary:#afafaf;--text-inverted:#0d0d0d;--text-inverted-static:#fff;--text-accent:var(--blue-200);--text-status-warning:var(--orange-200);--text-status-error:var(--red-200);--icon-primary:#e8e8e8;--icon-secondary:#cdcdcd;--icon-tertiary:#afafaf;--icon-inverted:#0d0d0d;--icon-inverted-static:#fff;--icon-accent:var(--blue-200);--icon-status-warning:var(--orange-200);--icon-status-error:var(--red-200);--interactive-bg-primary-default:#fff;--interactive-bg-primary-hover:#fffc;--interactive-bg-primary-press:#ffffffe5;--interactive-bg-primary-inactive:#fff;--interactive-bg-primary-selected:#fff;--interactive-bg-secondary-default:#fff0;--interactive-bg-secondary-hover:#ffffff1a;--interactive-bg-secondary-press:#ffffff0d;--interactive-bg-secondary-inactive:#fff0;--interactive-bg-secondary-selected:#ffffff1a;--interactive-bg-tertiary-default:#212121;--interactive-bg-tertiary-hover:#181818;--interactive-bg-tertiary-press:#0d0d0d;--interactive-bg-tertiary-inactive:#212121;--interactive-bg-tertiary-selected:#212121;--interactive-bg-accent-default:var(--blue-800);--interactive-bg-accent-hover:var(--blue-700);--interactive-bg-accent-muted-hover:#394a5b;--interactive-bg-accent-muted-context:#394a5b80;--interactive-bg-accent-press:var(--blue-600);--interactive-bg-accent-muted-press:#40484f;--interactive-bg-accent-inactive:var(--blue-800);--interactive-bg-danger-primary-default:var(--red-500);--interactive-bg-danger-primary-hover:var(--red-400);--interactive-bg-danger-primary-press:var(--red-600);--interactive-bg-danger-primary-inactive:var(--red-500);--interactive-bg-danger-secondary-default:#fff0;--interactive-bg-danger-secondary-hover:#fff0;--interactive-bg-danger-secondary-press:#fff0;--interactive-bg-danger-secondary-inactive:#fff0;--interactive-border-focus:#fff;--interactive-border-secondary-default:#ffffff26;--interactive-border-secondary-hover:#ffffff26;--interactive-border-secondary-press:#fff3;--interactive-border-secondary-inactive:#ffffff1a;--interactive-border-tertiary-default:#ffffff1a;--interactive-border-tertiary-hover:#ffffff26;--interactive-border-tertiary-press:#ffffff1a;--interactive-border-tertiary-inactive:#ffffff1a;--interactive-border-danger-secondary-default:var(--red-400);--interactive-border-danger-secondary-hover:var(--red-300);--interactive-border-danger-secondary-press:var(--red-500);--interactive-border-danger-secondary-inactive:var(--red-400);--interactive-label-primary-default:#0d0d0d;--interactive-label-primary-hover:#0d0d0d;--interactive-label-primary-press:#0d0d0d;--interactive-label-primary-inactive:#0d0d0d;--interactive-label-primary-selected:#0d0d0d;--interactive-label-secondary-default:#f3f3f3;--interactive-label-secondary-hover:#ffffffe5;--interactive-label-secondary-press:#fffc;--interactive-label-secondary-inactive:#f3f3f3;--interactive-label-secondary-selected:#f3f3f3;--interactive-label-tertiary-default:#cdcdcd;--interactive-label-tertiary-hover:#cdcdcd;--interactive-label-tertiary-press:#cdcdcd;--interactive-label-tertiary-inactive:#cdcdcd;--interactive-label-tertiary-selected:#cdcdcd;--interactive-label-accent-default:var(--blue-100);--interactive-label-accent-hover:var(--blue-100);--interactive-label-accent-press:var(--blue-100);--interactive-label-accent-inactive:var(--blue-100);--interactive-label-accent-selected:var(--blue-100);--interactive-label-danger-primary-default:#fff;--interactive-label-danger-primary-hover:#fff;--interactive-label-danger-primary-press:#fff;--interactive-label-danger-primary-inactive:#fff;--interactive-label-danger-secondary-default:var(--red-400);--interactive-label-danger-secondary-hover:var(--red-300);--interactive-label-danger-secondary-press:var(--red-500);--interactive-label-danger-secondary-inactive:var(--red-400);--interactive-icon-primary-default:#0d0d0d;--interactive-icon-primary-hover:#0d0d0d;--interactive-icon-primary-press:#0d0d0d;--interactive-icon-primary-selected:#0d0d0d;--interactive-icon-primary-inactive:#0d0d0d;--interactive-icon-secondary-default:#f3f3f3;--interactive-icon-secondary-hover:#ffffffe5;--interactive-icon-secondary-press:#fffc;--interactive-icon-secondary-selected:#f3f3f3;--interactive-icon-secondary-inactive:#f3f3f3;--interactive-icon-tertiary-default:#cdcdcd;--interactive-icon-tertiary-hover:#cdcdcd;--interactive-icon-tertiary-press:#cdcdcd;--interactive-icon-tertiary-selected:#cdcdcd;--interactive-icon-tertiary-inactive:#cdcdcd;--interactive-icon-accent-default:var(--blue-100);--interactive-icon-accent-hover:var(--blue-100);--interactive-icon-accent-press:var(--blue-100);--interactive-icon-accent-selected:var(--blue-100);--interactive-icon-accent-inactive:var(--blue-100);--interactive-icon-danger-primary-default:#fff;--interactive-icon-danger-primary-hover:#fff;--interactive-icon-danger-primary-press:#fff;--interactive-icon-danger-primary-inactive:#fff;--interactive-icon-danger-secondary-default:var(--red-400);--interactive-icon-danger-secondary-hover:var(--red-300);--interactive-icon-danger-secondary-press:var(--red-500);--interactive-icon-danger-secondary-inactive:var(--red-400);--utility-scrollbar:#fff3}.dark[data-oled]{--bg-primary:#000;--main-surface-primary:#000}@media (min-width:80rem){.stage-thread-flyout-preset-responsive{--stage-thread-flyout-preset-width:500px}}@media (min-width:96rem){.stage-thread-flyout-preset-responsive{--stage-thread-flyout-preset-width:700px}}@keyframes peek-top-animation{50%{translate:0 -85px}75%{translate:0 -85px}to{translate:0}}@keyframes peek-top-end-animation{to{translate:0}}@property --mask-shimmer-offset{syntax:"";inherits:false;initial-value:0%}@property --tw-mask-shimmer-duration{syntax:"