diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..e303b61 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,95 @@ +name: Backend CI + +on: + push: + branches: [dev, main, develop] + paths: + - 'backend/**' + - '.github/workflows/backend-ci.yml' + pull_request: + branches: [dev, main, develop] + paths: + - 'backend/**' + - '.github/workflows/backend-ci.yml' + +# Read-only by default; no job here needs to write to the repo. +permissions: + contents: read + +# Supersede in-flight runs for the same ref instead of queueing them. +concurrency: + group: backend-ci-${{ github.ref }} + cancel-in-progress: true + +env: + # Kept in step with flutter-ci.yml's FLUTTER_VERSION. Must ship Dart >=3.7 + # (bcrypt requires it) — the old 3.24.3 pin shipped Dart 3.5.3 and failed + # dependency resolution. + FLUTTER_VERSION: '3.44.1' + +defaults: + run: + working-directory: backend + +jobs: + backend: + name: Analyze, Format, Test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Don't leave GITHUB_TOKEN in .git/config for later steps to read. + persist-credentials: false + + # The backend is server-side Dart, but prisma_flutter_connector depends on + # the Flutter SDK, so pub resolution needs Flutter (not plain Dart). + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + # lib/generated/** is gitignored — it is derived from prisma/schema.prisma + # (copied verbatim from familiarise_web) and must be regenerated here. + - name: Regenerate Prisma client + freezed + run: ./scripts/regenerate-build.sh --prisma + + # `dart analyze` exits non-zero on *any* issue, including ~1k pre-existing + # style infos, so gate on severity instead. Parse the JSON report rather + # than the human-readable output: the text format labels these findings + # inconsistently (it showed 0 warnings where JSON showed 76), so a + # grep-based gate silently passed real warnings. + - name: Analyze (fail on errors + warnings) + run: | + dart analyze --format=json lib routes test > analyze.json || true + python3 - <<'EOF' + import json, sys, collections + with open('analyze.json') as f: + diagnostics = json.load(f)['diagnostics'] + bad = [d for d in diagnostics if d['severity'] in ('ERROR', 'WARNING')] + print('diagnostics by severity:', + dict(collections.Counter(d['severity'] for d in diagnostics))) + for d in bad: + loc = d['location'] + print(f"::error file={loc['file']}," + f"line={loc['range']['start']['line']}::" + f"{d['severity']} {d['code']}: {d['problemMessage']}") + if bad: + sys.exit(f"{len(bad)} error(s)/warning(s) found") + print('No errors or warnings.') + EOF + + # git ls-files lists tracked files only, so the gitignored generated + # client is excluded for free. + - name: Check formatting + run: dart format --output=none --set-exit-if-changed $(git ls-files '*.dart') + + - name: Test + run: dart test test/ + + # Ratchet: the JQB → typed-delegate migration finished at 0/0. This fails + # the build if raw JsonQueryBuilder or the removed raw finders creep back. + - name: JQB gate (no raw query builders) + run: ./scripts/jqb-gate.sh diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index b8b5137..7a8914a 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -2,14 +2,19 @@ name: Flutter CI/CD on: push: - branches: [main, develop] + # `dev` is this repo's default/base branch — without it this workflow + # never ran on day-to-day pushes or PRs. + branches: [dev, main, develop] pull_request: - branches: [main, develop] + branches: [dev, main, develop] release: types: [published] env: - FLUTTER_VERSION: '3.24.3' + # Must ship Dart >=3.7: bcrypt and skeletonizer both require it. The old + # 3.24.3 pin (Dart 3.5.3) failed resolution the moment this workflow + # actually ran again. + FLUTTER_VERSION: '3.44.1' JAVA_VERSION: '17' jobs: diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..affdf1e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,17 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + # `backend/` is a separate Dart package (dart_frog server) with its own + # pubspec, analysis_options and CI job. Its dependencies are not resolved + # in the Flutter workspace and its Prisma client is generated at build + # time, so analyzing it from here only produces phantom errors. + - backend/** + # Gitignored build output, including third-party SPM/CocoaPods checkouts + # (e.g. build/ios/SourcePackages/**) that carry their own example apps. + - build/** + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/backend/analysis_options.yaml b/backend/analysis_options.yaml index 0f306a0..59e48bd 100644 --- a/backend/analysis_options.yaml +++ b/backend/analysis_options.yaml @@ -1 +1,10 @@ -include: package:dart_frog_lint/recommended.yaml \ No newline at end of file +include: package:dart_frog_lint/recommended.yaml + +analyzer: + exclude: + # Generated from prisma/schema.prisma by prisma_flutter_connector, plus the + # freezed/json_serializable output. Gitignored and never hand-edited, so it + # is not worth linting (and it dominates the issue count otherwise). + - lib/generated/** + - "**/*.freezed.dart" + - "**/*.g.dart" diff --git a/backend/lib/database/database_client.dart b/backend/lib/database/database_client.dart index 1d22547..ef4dbb6 100644 --- a/backend/lib/database/database_client.dart +++ b/backend/lib/database/database_client.dart @@ -32,11 +32,15 @@ // final user = await db.users.findByEmail(email); // // OR type-safe: await db.prisma.feedback.create(data: ...); // -// WHEN TO UPDATE THE SCHEMA REGISTRY -// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -// Never by hand. Edit backend/prisma/schema.prisma (copied from the web -// repo, which owns migrations) and run: -// ./scripts/regenerate-build.sh --prisma +// WHEN THE SCHEMA CHANGES +// ~~~~~~~~~~~~~~~~~~~~~~~~ +// Never by hand. Copy the source-of-truth schema from familiarise_web (which +// owns migrations) and regenerate — registry, models, and delegates are all +// derived automatically: +// 1. cp ../familiarise_web/prisma/schema.prisma prisma/schema.prisma +// 2. ./scripts/regenerate-build.sh --prisma +// (The old hand-maintained buildSchemaRegistry() in schema_registry_builder.dart +// is deprecated and no longer wired in.) // // If you add a model and need a repository: // 1. Create a repository in backend/lib/database/repositories/ @@ -80,7 +84,7 @@ export '../generated/index.dart'; /// - QueryExecutor for query execution /// - JsonQueryBuilder for type-safe query building class DatabaseClient { - DatabaseClient._(this._executor, this._adapter, this._schema) { + DatabaseClient._(this._executor, this._adapter) { // Initialize type-safe PrismaClient _prisma = PrismaClient(adapter: _adapter); @@ -88,14 +92,17 @@ class DatabaseClient { _userRepository = UserRepository(_executor, _prisma); _accountRepository = AccountRepository(_executor, _prisma); _sessionRepository = SessionRepository(_executor, _userRepository, _prisma); - _consulteeProfileRepository = ConsulteeProfileRepository(_executor, _prisma); - _consultantProfileRepository = ConsultantProfileRepository(_executor, _prisma); + _consulteeProfileRepository = + ConsulteeProfileRepository(_executor, _prisma); + _consultantProfileRepository = + ConsultantProfileRepository(_executor, _prisma); _domainRepository = DomainRepository(_executor, _prisma); - _consultantExploreRepository = ConsultantExploreRepository(_executor); - _slotRepository = SlotRepository(_executor); - _appointmentRepository = AppointmentRepository(_executor); - _programsRepository = ProgramsRepository(_executor); - _checkoutRepository = CheckoutRepository(_executor); + _consultantExploreRepository = + ConsultantExploreRepository(_executor, _prisma); + _slotRepository = SlotRepository(_executor, _prisma); + _appointmentRepository = AppointmentRepository(_executor, _prisma); + _programsRepository = ProgramsRepository(_executor, _prisma); + _checkoutRepository = CheckoutRepository(_executor, _prisma); _webhookEventRepository = WebhookEventRepository(_executor, _prisma); _refundRepository = RefundRepository(_executor, _prisma); _disputeRepository = DisputeRepository(_executor, _prisma); @@ -103,7 +110,7 @@ class DatabaseClient { _reviewRepository = ReviewRepository(_executor, _prisma); _feedbackRepository = FeedbackRepository(_executor, _prisma); _meetingSessionRepository = MeetingSessionRepository(_executor, _prisma); - _dashboardRepository = DashboardRepository(_executor); + _dashboardRepository = DashboardRepository(_executor, _prisma); _verificationRepository = VerificationRepository(_executor, _prisma); _collaboratorRepository = CollaboratorRepository(_executor, _prisma); _referralRepository = ReferralRepository(_executor, _prisma); @@ -111,8 +118,7 @@ class DatabaseClient { ConsultantVerificationRepository(_executor, _prisma); _trialRepository = TrialRepository(_executor, _prisma); _waitlistRepository = WaitlistRepository(_executor, _prisma); - _payoutAccountRepository = - PayoutAccountRepository(_executor, _prisma); + _payoutAccountRepository = PayoutAccountRepository(_executor, _prisma); _appointmentDocumentRepository = AppointmentDocumentRepository(_executor, _prisma); _announcementRepository = AnnouncementRepository(_executor, _prisma); @@ -125,7 +131,6 @@ class DatabaseClient { static DatabaseClient? _instance; final QueryExecutor _executor; final PostgresAdapter _adapter; - final SchemaRegistry _schema; // Type-safe PrismaClient (use this for new code) late final PrismaClient _prisma; @@ -153,13 +158,11 @@ class DatabaseClient { late final VerificationRepository _verificationRepository; late final CollaboratorRepository _collaboratorRepository; late final ReferralRepository _referralRepository; - late final ConsultantVerificationRepository - _consultantVerificationRepository; + late final ConsultantVerificationRepository _consultantVerificationRepository; late final TrialRepository _trialRepository; late final WaitlistRepository _waitlistRepository; late final PayoutAccountRepository _payoutAccountRepository; - late final AppointmentDocumentRepository - _appointmentDocumentRepository; + late final AppointmentDocumentRepository _appointmentDocumentRepository; late final AnnouncementRepository _announcementRepository; late final MaintenanceRepository _maintenanceRepository; late final RecordingRepository _recordingRepository; @@ -168,25 +171,6 @@ class DatabaseClient { /// Build the schema registry from the generated registrations. /// - /// Models with @@map are additionally registered under their TABLE name - /// (e.g. both 'User' and 'users') so legacy JsonQueryBuilder calls that - /// reference .model('users') keep full field/relation metadata. - static SchemaRegistry _buildSchema() { - final schema = SchemaRegistry(); - registerAllModels(schema); - for (final modelName in schema.modelNames.toList()) { - final model = schema.getModel(modelName); - if (model != null && model.tableName != model.name) { - schema.registerModel(ModelSchema( - name: model.tableName, - tableName: model.tableName, - fields: model.fields, - relations: model.relations, - )); - } - } - return schema; - } /// Initialize the database client with a connection URL static Future initialize(String connectionUrl) async { @@ -202,37 +186,47 @@ class DatabaseClient { colonIndex == -1 ? userInfo : userInfo.substring(0, colonIndex); final password = colonIndex == -1 ? '' : userInfo.substring(colonIndex + 1); - // Honour ?sslmode=disable for local development databases; hosted - // Postgres (Supabase et al.) keeps the SSL requirement. - final sslMode = uri.queryParameters['sslmode'] == 'disable' + // Honour ?sslmode=disable, and auto-disable for localhost (no TLS on + // local Postgres); hosted Postgres (Supabase et al.) keeps SSL required. + final isLocal = uri.host == 'localhost' || uri.host == '127.0.0.1'; + final sslMode = (uri.queryParameters['sslmode'] == 'disable' || isLocal) ? pg.SslMode.disable : pg.SslMode.require; - final connection = await pg.Connection.open( - pg.Endpoint( - host: uri.host, - port: uri.port, - database: - uri.pathSegments.isNotEmpty ? uri.pathSegments.first : 'postgres', - username: username, - password: password, + // Pooled adapter (connector 0.7+): non-transactional statements run on + // connections borrowed from the pool; each transaction pins one dedicated + // connection. Replaces the previous single long-lived pg.Connection, whose + // silent staleness caused recurring 500s until a server restart. + final pool = pg.Pool.withEndpoints( + [ + pg.Endpoint( + host: uri.host, + port: uri.port, + database: + uri.pathSegments.isNotEmpty ? uri.pathSegments.first : 'postgres', + username: username, + password: password, + ), + ], + settings: pg.PoolSettings( + sslMode: sslMode, + maxConnectionCount: 8, + // Recycle pooled connections before hosted poolers kill them silently. + maxConnectionAge: const Duration(minutes: 30), ), - settings: pg.ConnectionSettings(sslMode: sslMode), ); - final adapter = PostgresAdapter(connection); - final schema = _buildSchema(); + final adapter = PostgresAdapter.pooled(pool); - // Populate global registry so PrismaClient delegates can resolve - // @@map table names (e.g., 'User' → 'users' table). - for (final modelName in schema.modelNames) { - final model = schema.getModel(modelName); - if (model != null) schemaRegistry.registerModel(model); - } + // Populate the global registry from the GENERATED schema (all models, + // @@map/@map-aware, regenerated from prisma/schema.prisma). This replaces + // the hand-maintained buildSchemaRegistry() so JQB and typed PrismaClient + // delegates always match the current schema without manual upkeep. + registerAllModels(schemaRegistry); - final executor = QueryExecutor(adapter: adapter, schema: schema); + final executor = QueryExecutor(adapter: adapter, schema: schemaRegistry); - _instance = DatabaseClient._(executor, adapter, schema); + _instance = DatabaseClient._(executor, adapter); return _instance!; } @@ -333,8 +327,7 @@ class DatabaseClient { WaitlistRepository get waitlists => _waitlistRepository; /// Payout account repository (for consultant bank/UPI accounts) - PayoutAccountRepository get payoutAccounts => - _payoutAccountRepository; + PayoutAccountRepository get payoutAccounts => _payoutAccountRepository; /// Appointment document repository (for document review workflow) AppointmentDocumentRepository get appointmentDocuments => diff --git a/backend/lib/database/repositories/account_repository.dart b/backend/lib/database/repositories/account_repository.dart index 8eeaad1..be10c2c 100644 --- a/backend/lib/database/repositories/account_repository.dart +++ b/backend/lib/database/repositories/account_repository.dart @@ -21,12 +21,13 @@ class AccountRepository extends BaseRepository { String userId, String providerId, ) async { - return _prisma.account.findFirstRaw( - where: { - 'userId': userId, - 'providerId': providerId, - }, + final result = await _prisma.account.findFirst( + where: AccountWhereInput( + userId: StringFilter(equals: userId), + providerId: StringFilter(equals: providerId), + ), ); + return result?.toJson(); } /// Find credential account by userId @@ -43,16 +44,17 @@ class AccountRepository extends BaseRepository { required String accountId, required String hashedPassword, }) async { - final query = JsonQueryBuilder() - .model('accounts') - .action(QueryAction.update) - .where({'id': accountId}) - .data({ - 'password': hashedPassword, - 'updatedAt': nowIso8601, - }).build(); - - return executeQueryAsSingleMap(query); + // updateMany so a missing account returns null (declared contract) + // rather than throwing out of the typed update. + final affected = await _prisma.account.updateMany( + where: AccountWhereInput(id: StringFilter(equals: accountId)), + data: UpdateAccountInput(password: hashedPassword), + ); + if (affected == 0) return null; + final result = await _prisma.account.findFirst( + where: AccountWhereInput(id: StringFilter(equals: accountId)), + ); + return result?.toJson(); } /// Create an OAuth account link @@ -67,23 +69,19 @@ class AccountRepository extends BaseRepository { String? idToken, TransactionExecutor? txn, }) async { - final query = - JsonQueryBuilder().model('accounts').action(QueryAction.create).data({ - 'id': id, - 'userId': userId, - 'providerId': providerId, - 'accountId': accountId, - 'accessToken': accessToken, - 'idToken': idToken, - 'createdAt': nowIso8601, - 'updatedAt': nowIso8601, - }).build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); - if (result == null) { - throw Exception('Failed to create OAuth account in database'); - } - return result; + // id/createdAt/updatedAt are autofilled by the schema defaults; callers + // should use the returned row's id (CreateAccountInput has no id param). + final delegate = txn == null ? _prisma.account : AccountDelegate(txn); + final result = await delegate.create( + data: CreateAccountInput( + userId: userId, + providerId: providerId, + accountId: accountId, + accessToken: accessToken, + idToken: idToken, + ), + ); + return result.toJson(); } /// Create a credentials account for email/password users @@ -96,21 +94,17 @@ class AccountRepository extends BaseRepository { required String hashedPassword, TransactionExecutor? txn, }) async { - final query = - JsonQueryBuilder().model('accounts').action(QueryAction.create).data({ - 'id': id, - 'userId': userId, - 'providerId': 'credential', - 'accountId': userId, - 'password': hashedPassword, - 'createdAt': nowIso8601, - 'updatedAt': nowIso8601, - }).build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); - if (result == null) { - throw Exception('Failed to create credentials account in database'); - } - return result; + // id/createdAt/updatedAt are autofilled by the schema defaults; callers + // should use the returned row's id (CreateAccountInput has no id param). + final delegate = txn == null ? _prisma.account : AccountDelegate(txn); + final result = await delegate.create( + data: CreateAccountInput( + userId: userId, + providerId: 'credential', + accountId: userId, + password: hashedPassword, + ), + ); + return result.toJson(); } } diff --git a/backend/lib/database/repositories/announcement_repository.dart b/backend/lib/database/repositories/announcement_repository.dart index c3f9023..403082e 100644 --- a/backend/lib/database/repositories/announcement_repository.dart +++ b/backend/lib/database/repositories/announcement_repository.dart @@ -9,16 +9,18 @@ class AnnouncementRepository extends BaseRepository { /// Get active announcements (within date range, active status). Future>> getActive() async { - final now = nowIso8601; - return _prisma.announcement.findManyRaw( - where: { - 'isActive': true, - 'startDate': {'lte': now}, - 'OR': [ - {'endDate': {'equals': null}}, - {'endDate': {'gte': now}}, - ], - }, + final now = DateTime.now().toUtc(); + // The typed DateTimeFilter cannot express `endDate IS NULL`, so the + // "no end date" half of the old raw OR-clause is applied in Dart. + final results = await _prisma.announcement.findMany( + where: AnnouncementWhereInput( + isActive: const BooleanFilter(equals: true), + startDate: DateTimeFilter(lte: now), + ), ); + return results + .where((a) => a.endDate == null || !a.endDate!.isBefore(now)) + .map((a) => a.toJson()) + .toList(); } } diff --git a/backend/lib/database/repositories/appointment_document_repository.dart b/backend/lib/database/repositories/appointment_document_repository.dart index 6c8e7ee..021a312 100644 --- a/backend/lib/database/repositories/appointment_document_repository.dart +++ b/backend/lib/database/repositories/appointment_document_repository.dart @@ -1,12 +1,9 @@ import 'package:backend/database/repositories/base_repository.dart'; import 'package:backend/generated/index.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; - /// Repository for appointment document operations. /// -/// Uses JsonQueryBuilder for creates (foreign keys) and PrismaClient -/// typed delegates for reads/updates. +/// Uses PrismaClient typed delegates. class AppointmentDocumentRepository extends BaseRepository { AppointmentDocumentRepository(super._executor, this._prisma); @@ -25,38 +22,36 @@ class AppointmentDocumentRepository extends BaseRepository { String uploadedByRole = 'CONSULTEE', String? responseToDocumentId, }) async { - final now = nowIso8601; - final query = JsonQueryBuilder() - .model('AppointmentDocument') - .action(QueryAction.create) - .data({ - 'appointmentId': appointmentId, - 'fileName': fileName, - 'originalName': originalName, - 'fileSize': fileSize, - 'mimeType': mimeType, - 'fileUrl': fileUrl, - 'storagePath': storagePath, - 'description': description, - 'reviewStatus': DocumentReviewStatus.pending.name, - 'uploadedByRole': uploadedByRole, - 'responseToDocumentId': responseToDocumentId, - 'uploadedAt': now, - 'updatedAt': now, - }).build(); - - final result = await executeQueryAsSingleMap(query); - if (result == null) throw Exception('Failed to create document'); - return result; + // id/uploadedAt/updatedAt autofilled; reviewStatus defaults to PENDING + // on the typed create input. + final result = await _prisma.appointmentDocument.create( + data: CreateAppointmentDocumentInput( + appointmentId: appointmentId, + fileName: fileName, + originalName: originalName, + fileSize: fileSize, + mimeType: mimeType, + fileUrl: fileUrl, + storagePath: storagePath, + description: description, + uploadedByRole: DocumentUploadRole.values + .firstWhere((e) => e.toJson() == uploadedByRole), + responseToDocumentId: responseToDocumentId, + ), + ); + return result.toJson(); } /// Get all documents for an appointment. Future>> findByAppointment( String appointmentId, ) async { - return _prisma.appointmentDocument.findManyRaw( - where: {'appointmentId': appointmentId}, + final results = await _prisma.appointmentDocument.findMany( + where: AppointmentDocumentWhereInput( + appointmentId: StringFilter(equals: appointmentId), + ), ); + return results.map((r) => r.toJson()).toList(); } /// Get a document by ID. @@ -79,7 +74,9 @@ class AppointmentDocumentRepository extends BaseRepository { reviewStatus: status, reviewNotes: reviewNotes, reviewedAt: DateTime.now().toUtc(), - reviewedBy: reviewedBy, + // reviewedBy was FK-ified (#676): raw String -> reviewedById scalar + // + reviewedBy User? relation. Set the scalar FK directly. + reviewedById: reviewedBy, ), ); } diff --git a/backend/lib/database/repositories/appointment_repository.dart b/backend/lib/database/repositories/appointment_repository.dart index 22d672d..3aba826 100644 --- a/backend/lib/database/repositories/appointment_repository.dart +++ b/backend/lib/database/repositories/appointment_repository.dart @@ -1,7 +1,6 @@ import 'package:backend/database/repositories/base_repository.dart'; +import 'package:backend/generated/index.dart'; import 'package:backend/utils/slot_lock.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// Exception thrown when user already has an active booking with a consultant class DuplicateBookingException implements Exception { @@ -28,37 +27,35 @@ class SlotConflictException implements Exception { /// Handles creation, retrieval, and management of appointments /// for both consultation and subscription bookings. /// -/// Uses JsonQueryBuilder for type-safe queries, eliminating SQL injection risks. +/// Uses the typed PrismaClient surface, eliminating SQL injection risks. class AppointmentRepository extends BaseRepository { /// Month abbreviations for date formatting - static const _months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; /// Create an appointment repository with the given executor - AppointmentRepository(super._executor); + AppointmentRepository(super._executor, this._prisma); - final _uuid = const Uuid(); + final PrismaClient _prisma; /// Active statuses that block new bookings static const _activeStatuses = [ - 'PENDING', - 'APPROVED', - 'APPROVED_PENDING_PAYMENT', - 'SCHEDULED', + AppointmentStatus.pending, + AppointmentStatus.approved, + AppointmentStatus.approvedPendingPayment, + AppointmentStatus.scheduled, ]; + /// Convert a raw status string to the [AppointmentStatus] enum + AppointmentStatus _appointmentStatusFromString(String value) => + AppointmentStatus.values.firstWhere((e) => e.toJson() == value); + + /// Convert a raw status string to the [TrialSessionStatus] enum + TrialSessionStatus _trialSessionStatusFromString(String value) => + TrialSessionStatus.values.firstWhere((e) => e.toJson() == value); + + /// Convert a raw type string to the [AppointmentsType] enum + AppointmentsType _appointmentsTypeFromString(String value) => + AppointmentsType.values.firstWhere((e) => e.toJson() == value); + /// Check if user has an active consultation booking with a consultant /// /// Returns true if there's already a PENDING, APPROVED, or SCHEDULED @@ -69,28 +66,24 @@ class AppointmentRepository extends BaseRepository { }) async { // We need to check consultations that have a plan belonging to this consultant // First get all consultation plan IDs for this consultant - final plansQuery = JsonQueryBuilder() - .model('ConsultationPlan') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}).select( - {'id': true}).build(); - - final plans = await executeQueryAsMaps(plansQuery); + final plans = await _prisma.consultationPlan.findManyProjected( + where: ConsultationPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [ConsultationPlanScalarField.id], + ); if (plans.isEmpty) return false; final planIds = plans.map((p) => p['id'] as String).toList(); // Check if there's an active consultation with any of these plans - final countQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.count) - .where({ - 'requestedById': consulteeProfileId, - 'consultationPlanId': {'in': planIds}, - 'requestStatus': {'in': _activeStatuses}, - }).build(); - - final count = await executeCount(countQuery); + final count = await _prisma.consultation.count( + where: ConsultationWhereInput( + requestedById: StringFilter(equals: consulteeProfileId), + consultationPlanId: StringFilter(in_: planIds), + status: const AppointmentStatusFilter(in_: _activeStatuses), + ), + ); return count > 0; } @@ -103,28 +96,24 @@ class AppointmentRepository extends BaseRepository { required String consultantProfileId, }) async { // Get all subscription plan IDs for this consultant - final plansQuery = JsonQueryBuilder() - .model('SubscriptionPlan') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}).select( - {'id': true}).build(); - - final plans = await executeQueryAsMaps(plansQuery); + final plans = await _prisma.subscriptionPlan.findManyProjected( + where: SubscriptionPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [SubscriptionPlanScalarField.id], + ); if (plans.isEmpty) return false; final planIds = plans.map((p) => p['id'] as String).toList(); // Check if there's an active subscription with any of these plans - final countQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.count) - .where({ - 'requestedById': consulteeProfileId, - 'subscriptionPlanId': {'in': planIds}, - 'requestStatus': {'in': _activeStatuses}, - }).build(); - - final count = await executeCount(countQuery); + final count = await _prisma.subscription.count( + where: SubscriptionWhereInput( + requestedById: StringFilter(equals: consulteeProfileId), + subscriptionPlanId: StringFilter(in_: planIds), + status: const AppointmentStatusFilter(in_: _activeStatuses), + ), + ); return count > 0; } @@ -148,38 +137,37 @@ class AppointmentRepository extends BaseRepository { } final conflicts = []; - final tentativeCutoff = DateTime.now() - .subtract(const Duration(seconds: 60)) - .toUtc() - .toIso8601String(); + final tentativeCutoff = + DateTime.now().subtract(const Duration(seconds: 60)).toUtc(); for (final slotStart in slotStartTimes) { final slotEnd = slotStart.add(Duration(minutes: durationMinutes)); // Step 2: Check for overlapping slots in those appointments // A slot overlaps if: existing.start < new.end AND existing.end > new.start - final query = JsonQueryBuilder() - .model('SlotOfAppointment') - .action(QueryAction.count) - .where({ - 'appointmentId': FilterOperators.in_(appointmentIds), - // Check either confirmed slots OR recent tentative slots - 'OR': [ - {'isTentative': false}, - { - 'AND': [ - {'isTentative': true}, - { - 'createdAt': {'gte': tentativeCutoff} - }, - ], - }, - ], - 'startsAt': {'lt': slotEnd.toUtc().toIso8601String()}, - 'endsAt': {'gt': slotStart.toUtc().toIso8601String()}, - }).build(); - - final count = await executeCount(query); + final count = await _prisma.slotOfAppointment.count( + where: SlotOfAppointmentWhereInput( + appointmentId: StringFilter(in_: appointmentIds), + // Check either confirmed slots OR recent tentative slots + OR: [ + const SlotOfAppointmentWhereInput( + isTentative: BooleanFilter(equals: false), + ), + SlotOfAppointmentWhereInput( + AND: [ + const SlotOfAppointmentWhereInput( + isTentative: BooleanFilter(equals: true), + ), + SlotOfAppointmentWhereInput( + createdAt: DateTimeFilter(gte: tentativeCutoff), + ), + ], + ), + ], + startsAt: DateTimeFilter(lt: slotEnd.toUtc()), + endsAt: DateTimeFilter(gt: slotStart.toUtc()), + ), + ); if (count > 0) { conflicts.add(slotStart); } @@ -194,71 +182,67 @@ class AppointmentRepository extends BaseRepository { final appointmentIds = []; // Get consultation plan IDs for this consultant - final consultationPlansQuery = JsonQueryBuilder() - .model('ConsultationPlan') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}).select( - {'id': true}).build(); - final consultationPlans = await executeQueryAsMaps(consultationPlansQuery); + final consultationPlans = await _prisma.consultationPlan.findManyProjected( + where: ConsultationPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [ConsultationPlanScalarField.id], + ); final consultationPlanIds = consultationPlans.map((p) => p['id'] as String).toList(); // Get subscription plan IDs for this consultant - final subscriptionPlansQuery = JsonQueryBuilder() - .model('SubscriptionPlan') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}).select( - {'id': true}).build(); - final subscriptionPlans = await executeQueryAsMaps(subscriptionPlansQuery); + final subscriptionPlans = await _prisma.subscriptionPlan.findManyProjected( + where: SubscriptionPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [SubscriptionPlanScalarField.id], + ); final subscriptionPlanIds = subscriptionPlans.map((p) => p['id'] as String).toList(); // Get consultation IDs for those plans if (consultationPlanIds.isNotEmpty) { - final consultationsQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findMany) - .where({ - 'consultationPlanId': FilterOperators.in_(consultationPlanIds) - }).select({'id': true}).build(); - final consultations = await executeQueryAsMaps(consultationsQuery); + final consultations = await _prisma.consultation.findManyProjected( + where: ConsultationWhereInput( + consultationPlanId: StringFilter(in_: consultationPlanIds), + ), + select: const [ConsultationScalarField.id], + ); final consultationIds = consultations.map((c) => c['id'] as String).toList(); // Get appointment IDs for those consultations if (consultationIds.isNotEmpty) { - final appointmentsQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'consultationId': FilterOperators.in_(consultationIds) - }).select({'id': true}).build(); - final appointments = await executeQueryAsMaps(appointmentsQuery); + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput( + consultationId: StringFilter(in_: consultationIds), + ), + select: const [AppointmentScalarField.id], + ); appointmentIds.addAll(appointments.map((a) => a['id'] as String)); } } // Get subscription IDs for those plans if (subscriptionPlanIds.isNotEmpty) { - final subscriptionsQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findMany) - .where({ - 'subscriptionPlanId': FilterOperators.in_(subscriptionPlanIds) - }).select({'id': true}).build(); - final subscriptions = await executeQueryAsMaps(subscriptionsQuery); + final subscriptions = await _prisma.subscription.findManyProjected( + where: SubscriptionWhereInput( + subscriptionPlanId: StringFilter(in_: subscriptionPlanIds), + ), + select: const [SubscriptionScalarField.id], + ); final subscriptionIds = subscriptions.map((s) => s['id'] as String).toList(); // Get appointment IDs for those subscriptions if (subscriptionIds.isNotEmpty) { - final appointmentsQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'subscriptionId': FilterOperators.in_(subscriptionIds) - }).select({'id': true}).build(); - final appointments = await executeQueryAsMaps(appointmentsQuery); + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput( + subscriptionId: StringFilter(in_: subscriptionIds), + ), + select: const [AppointmentScalarField.id], + ); appointmentIds.addAll(appointments.map((a) => a['id'] as String)); } } @@ -297,15 +281,12 @@ class AppointmentRepository extends BaseRepository { } // Get the plan to verify it exists and get duration - final planQuery = JsonQueryBuilder() - .model('ConsultationPlan') - .action(QueryAction.findFirst) - .where({ - 'id': planId, - 'consultantProfileId': consultantProfileId, - }).build(); - - final plan = await executeQueryAsSingleMap(planQuery); + final plan = await _prisma.consultationPlan.findFirstProjected( + where: ConsultationPlanWhereInput( + id: StringFilter(equals: planId), + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + ); if (plan == null) { throw Exception('Consultation plan not found'); @@ -349,82 +330,66 @@ class AppointmentRepository extends BaseRepository { ); } - final now = nowIso8601; - - // Create the booking within a transaction + // Create the booking within a transaction. + // + // This block stays on executeInTransaction (instead of + // _prisma.$transaction) because the m2m junction insert below requires + // raw SQL on the transaction executor, which the typed transaction + // client does not expose. Typed delegates are bound to the transaction + // executor so every other statement uses the typed surface. return await executeInTransaction((txn) async { - // Create Consultation record - final consultationId = _uuid.v4(); - final consultationQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.create) - .data({ - 'id': consultationId, - 'consultationPlanId': planId, - 'requestedById': requestedById, - 'requestStatus': 'PENDING', - 'requestNotes': message, - 'bookingSource': 'REQUEST_SUBMITTED', - 'requestedAt': now, - 'createdAt': now, - 'updatedAt': now, - }).build(); - await txn.executeMutation(consultationQuery); + final consultationDelegate = ConsultationDelegate(txn); + final appointmentDelegate = AppointmentDelegate(txn); + final slotDelegate = SlotOfAppointmentDelegate(txn); + + // Create Consultation record (id/requestedAt/timestamps autofilled; + // status defaults to PENDING, bookingSource to REQUEST_SUBMITTED) + final consultation = await consultationDelegate.create( + data: CreateConsultationInput( + consultationPlanId: planId, + requestedById: requestedById, + requestNotes: message, + ), + ); // Create Appointment record - final appointmentId = _uuid.v4(); - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.create) - .data({ - 'id': appointmentId, - 'appointmentType': 'CONSULTATION', - 'consultationId': consultationId, - 'createdAt': now, - 'updatedAt': now, - }).build(); - await txn.executeMutation(appointmentQuery); - - // Create SlotOfAppointment records using createMany - final slotsData = slotStartTimes.map((slotStart) { - final slotEnd = slotStart.add(Duration(minutes: durationMinutes)); - return { - 'id': _uuid.v4(), - 'appointmentId': appointmentId, - 'startsAt': slotStart.toUtc().toIso8601String(), - 'endsAt': slotEnd.toUtc().toIso8601String(), - 'isTentative': true, - 'createdAt': now, - 'updatedAt': now, - }; - }).toList(); - - final slotsQuery = JsonQueryBuilder() - .model('SlotOfAppointment') - .action(QueryAction.createMany) - .data({'data': slotsData}).build(); - await txn.executeMutation(slotsQuery); + final appointment = await appointmentDelegate.create( + data: CreateAppointmentInput( + appointmentType: AppointmentsType.consultation, + consultationId: consultation.id, + ), + ); + + // Create SlotOfAppointment records and keep the generated ids + final slots = await slotDelegate.createManyAndReturn( + data: [ + for (final slotStart in slotStartTimes) + CreateSlotOfAppointmentInput( + appointmentId: appointment.id, + startsAt: slotStart.toUtc(), + endsAt: + slotStart.add(Duration(minutes: durationMinutes)).toUtc(), + isTentative: true, + ), + ], + ); // Link users to slots via junction table - // Note: For bulk operations with createMany, raw SQL is more efficient. - // For single-record operations, use the v0.3.0 connect API: - // JsonQueryBuilder().model('SlotOfAppointment').action(QueryAction.create) - // .data({'id': slotId, 'users': {'connect': [{'id': userId}]}}).build() // Column B references users.id, so we use userId (not consulteeProfileId) - for (final slotData in slotsData) { + // EXEMPT(jqb-gate): implicit m2m junction insert — no typed surface for join tables. + for (final slot in slots) { await txn.executeMutationRaw( r'INSERT INTO "_SlotOfAppointmentToUser" ("A", "B") VALUES ($1, $2)', - [slotData['id'], userId], + [slot.id, userId], ); } // Fetch and return the created booking - final resultQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findUnique) - .where({'id': consultationId}).build(); - - final result = await txn.executeQueryAsSingleMap(resultQuery); + final result = await consultationDelegate.findFirstProjected( + where: ConsultationWhereInput( + id: StringFilter(equals: consultation.id), + ), + ); if (result == null) { throw Exception('Failed to create consultation'); } @@ -473,15 +438,12 @@ class AppointmentRepository extends BaseRepository { } // Verify plan exists and get duration - final planQuery = JsonQueryBuilder() - .model('SubscriptionPlan') - .action(QueryAction.findFirst) - .where({ - 'id': planId, - 'consultantProfileId': consultantProfileId, - }).build(); - - final plan = await executeQueryAsSingleMap(planQuery); + final plan = await _prisma.subscriptionPlan.findFirstProjected( + where: SubscriptionPlanWhereInput( + id: StringFilter(equals: planId), + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + ); if (plan == null) { throw Exception('Subscription plan not found'); @@ -508,32 +470,20 @@ class AppointmentRepository extends BaseRepository { targetDay, ); - final now = nowIso8601; - final subscriptionId = _uuid.v4(); - - // Create the subscription booking - final createQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.create) - .data({ - 'id': subscriptionId, - 'subscriptionPlanId': planId, - 'requestedById': requestedById, - 'requestStatus': 'PENDING', - 'schedulingPeriodStartsAt': - schedulingPeriodStart.toUtc().toIso8601String(), - 'schedulingPeriodEndsAt': schedulingPeriodEnd.toUtc().toIso8601String(), - 'schedulingTimezone': timezone ?? 'UTC', - 'requestNotes': message, - 'bookingSource': 'REQUEST_SUBMITTED', - 'requestedAt': now, - 'createdAt': now, - 'updatedAt': now, - }).build(); - - await executeMutation(createQuery); - - return getBookingById(subscriptionId, type: 'SUBSCRIPTION'); + // Create the subscription booking (id/requestedAt/timestamps autofilled; + // status defaults to PENDING, bookingSource to REQUEST_SUBMITTED) + final subscription = await _prisma.subscription.create( + data: CreateSubscriptionInput( + subscriptionPlanId: planId, + requestedById: requestedById, + schedulingPeriodStartsAt: schedulingPeriodStart.toUtc(), + schedulingPeriodEndsAt: schedulingPeriodEnd.toUtc(), + schedulingTimezone: timezone ?? 'UTC', + requestNotes: message, + ), + ); + + return getBookingById(subscription.id, type: 'SUBSCRIPTION'); } /// Get user's bookings with pagination and optional status filter @@ -551,13 +501,12 @@ class AppointmentRepository extends BaseRepository { if (asConsultant) { // Consultant view: fetch bookings for plans owned by this consultant - final consultantProfileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findFirst) - .where({'userId': userId}).build(); - final consultantProfile = - await executeQueryAsSingleMap(consultantProfileQuery); + await _prisma.consultantProfile.findFirstProjected( + where: ConsultantProfileWhereInput( + userId: StringFilter(equals: userId), + ), + ); final consultantProfileId = consultantProfile?['id'] as String?; if (consultantProfileId != null) { @@ -569,32 +518,28 @@ class AppointmentRepository extends BaseRepository { allBookings.addAll(consultantBookings); // 2. Fetch SUBSCRIPTIONS - final subscriptionBookings = - await _fetchConsultantSubscriptionBookings( + final subscriptionBookings = await _fetchConsultantSubscriptionBookings( consultantProfileId: consultantProfileId, status: status, ); allBookings.addAll(subscriptionBookings); // 3. Fetch WEBINARS - final webinarBookings = - await _fetchConsultantWebinarBookings( + final webinarBookings = await _fetchConsultantWebinarBookings( consultantProfileId: consultantProfileId, status: status, ); allBookings.addAll(webinarBookings); // 4. Fetch CLASSES - final classBookings = - await _fetchConsultantClassBookings( + final classBookings = await _fetchConsultantClassBookings( consultantProfileId: consultantProfileId, status: status, ); allBookings.addAll(classBookings); // 5. Fetch TRIAL SESSIONS - final trialBookings = - await _fetchConsultantTrialBookings( + final trialBookings = await _fetchConsultantTrialBookings( consultantProfileId: consultantProfileId, status: status, ); @@ -603,12 +548,11 @@ class AppointmentRepository extends BaseRepository { } else { // Consultee view: existing behavior // Get consultee profile ID for the user - final profileQuery = JsonQueryBuilder() - .model('ConsulteeProfile') - .action(QueryAction.findFirst) - .where({'userId': userId}).build(); - - final profile = await executeQueryAsSingleMap(profileQuery); + final profile = await _prisma.consulteeProfile.findFirstProjected( + where: ConsulteeProfileWhereInput( + userId: StringFilter(equals: userId), + ), + ); final consulteeProfileId = profile?['id'] as String?; // 1. Fetch CONSULTATIONS (uses ConsulteeProfile) @@ -680,15 +624,19 @@ class AppointmentRepository extends BaseRepository { String? status, }) async { // Get all consultation plans for this consultant - final plansQuery = JsonQueryBuilder() - .model('ConsultationPlan') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}) - .select({'id': true, 'title': true, 'price': true, - 'priceCurrency': true, 'durationInHours': true}) - .build(); - - final plans = await executeQueryAsMaps(plansQuery); + final plans = await _prisma.consultationPlan.findManyProjected( + where: ConsultationPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [ + ConsultationPlanScalarField.id, + ConsultationPlanScalarField.title, + ConsultationPlanScalarField.price, + ConsultationPlanScalarField.priceCurrency, + ConsultationPlanScalarField.durationInHours, + ], + ); + if (plans.isEmpty) return []; final planIds = plans.map((p) => p['id'] as String).toList(); @@ -697,42 +645,36 @@ class AppointmentRepository extends BaseRepository { planLookup[p['id'] as String] = p; } - final where = { - 'consultationPlanId': {'in': planIds}, - }; - if (status != null) { - where['requestStatus'] = status; - } + final consultations = await _prisma.consultation.findManyProjected( + where: ConsultationWhereInput( + consultationPlanId: StringFilter(in_: planIds), + status: status != null + ? AppointmentStatusFilter( + equals: _appointmentStatusFromString(status), + ) + : null, + ), + include: const ConsultationInclude( + requestedBy: ConsulteeProfileInclude(user: UserInclude()), + ), + orderBy: {'createdAt': 'desc'}, + ); - final consultationsQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findMany) - .where(where) - .include({ - 'requestedBy': { - 'include': {'user': true}, - }, - }) - .orderBy({'createdAt': 'desc'}) - .build(); - - final consultations = await executeQueryAsMaps(consultationsQuery); if (consultations.isEmpty) return []; // Fetch appointments with slots final consultationIds = consultations.map((c) => c['id'] as String).toList(); - final appointmentsQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'consultationId': {'in': consultationIds}, - }) - .include({'slotsOfAppointment': true}) - .build(); + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput( + consultationId: StringFilter(in_: consultationIds), + ), + include: const AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(), + ), + ); - final appointments = await executeQueryAsMaps(appointmentsQuery); final appointmentLookup = >{}; for (final a in appointments) { final cId = a['consultationId'] as String?; @@ -748,10 +690,8 @@ class AppointmentRepository extends BaseRepository { final slots = appointment?['slotsOfAppointment'] as List?; // Get consultee info - final requestedBy = - c['requestedBy'] as Map?; - final consulteeUser = - requestedBy?['user'] as Map?; + final requestedBy = c['requestedBy'] as Map?; + final consulteeUser = requestedBy?['user'] as Map?; bookings.add({ 'id': c['id'], @@ -769,8 +709,7 @@ class AppointmentRepository extends BaseRepository { 'consulteeUserId': consulteeUser?['id'], 'consulteeName': consulteeUser?['name'], 'consulteeImage': consulteeUser?['image'], - if (slots != null && slots.isNotEmpty) - 'slots': _formatSlots(slots), + if (slots != null && slots.isNotEmpty) 'slots': _formatSlots(slots), }); } @@ -783,22 +722,21 @@ class AppointmentRepository extends BaseRepository { String? status, }) async { // Get all subscription plans for this consultant - final plansQuery = JsonQueryBuilder() - .model('SubscriptionPlan') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}) - .select({ - 'id': true, - 'title': true, - 'price': true, - 'priceCurrency': true, - 'totalSessions': true, - 'sessionDurationInHours': true, - 'durationInMonths': true, - }) - .build(); - - final plans = await executeQueryAsMaps(plansQuery); + final plans = await _prisma.subscriptionPlan.findManyProjected( + where: SubscriptionPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [ + SubscriptionPlanScalarField.id, + SubscriptionPlanScalarField.title, + SubscriptionPlanScalarField.price, + SubscriptionPlanScalarField.priceCurrency, + SubscriptionPlanScalarField.totalSessions, + SubscriptionPlanScalarField.sessionDurationInHours, + SubscriptionPlanScalarField.durationInMonths, + ], + ); + if (plans.isEmpty) return []; final planIds = plans.map((p) => p['id'] as String).toList(); @@ -808,26 +746,21 @@ class AppointmentRepository extends BaseRepository { } // Fetch subscriptions for those plans with consultee info - final where = { - 'subscriptionPlanId': {'in': planIds}, - }; - if (status != null) { - where['requestStatus'] = status; - } + final subscriptions = await _prisma.subscription.findManyProjected( + where: SubscriptionWhereInput( + subscriptionPlanId: StringFilter(in_: planIds), + status: status != null + ? AppointmentStatusFilter( + equals: _appointmentStatusFromString(status), + ) + : null, + ), + include: const SubscriptionInclude( + requestedBy: ConsulteeProfileInclude(user: UserInclude()), + ), + orderBy: {'createdAt': 'desc'}, + ); - final subscriptionsQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findMany) - .where(where) - .include({ - 'requestedBy': { - 'include': {'user': true}, - }, - }) - .orderBy({'createdAt': 'desc'}) - .build(); - - final subscriptions = await executeQueryAsMaps(subscriptionsQuery); if (subscriptions.isEmpty) return []; final bookings = >[]; @@ -835,10 +768,8 @@ class AppointmentRepository extends BaseRepository { final planId = s['subscriptionPlanId'] as String?; final plan = planId != null ? planLookup[planId] : null; - final requestedBy = - s['requestedBy'] as Map?; - final consulteeUser = - requestedBy?['user'] as Map?; + final requestedBy = s['requestedBy'] as Map?; + final consulteeUser = requestedBy?['user'] as Map?; bookings.add({ 'id': s['id'], @@ -872,21 +803,20 @@ class AppointmentRepository extends BaseRepository { String? status, }) async { // Get all webinar plans for this consultant - final plansQuery = JsonQueryBuilder() - .model('WebinarPlan') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}) - .select({ - 'id': true, - 'title': true, - 'price': true, - 'priceCurrency': true, - 'durationInHours': true, - 'maxParticipants': true, - }) - .build(); - - final plans = await executeQueryAsMaps(plansQuery); + final plans = await _prisma.webinarPlan.findManyProjected( + where: WebinarPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [ + WebinarPlanScalarField.id, + WebinarPlanScalarField.title, + WebinarPlanScalarField.price, + WebinarPlanScalarField.priceCurrency, + WebinarPlanScalarField.durationInHours, + WebinarPlanScalarField.maxParticipants, + ], + ); + if (plans.isEmpty) return []; final planIds = plans.map((p) => p['id'] as String).toList(); @@ -896,15 +826,12 @@ class AppointmentRepository extends BaseRepository { } // Get webinar records for those plans - final webinarsQuery = JsonQueryBuilder() - .model('Webinar') - .action(QueryAction.findMany) - .where({ - 'webinarPlanId': {'in': planIds}, - }) - .build(); - - final webinars = await executeQueryAsMaps(webinarsQuery); + final webinars = await _prisma.webinar.findManyProjected( + where: WebinarWhereInput( + webinarPlanId: StringFilter(in_: planIds), + ), + ); + if (webinars.isEmpty) return []; // Apply status filter if provided @@ -918,23 +845,16 @@ class AppointmentRepository extends BaseRepository { if (filteredWebinars.isEmpty) return []; // Batch fetch appointments with slots + enrolled users for all webinars - final webinarIds = - filteredWebinars.map((w) => w['id'] as String).toList(); - - final appointmentsQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'webinarId': {'in': webinarIds}, - }) - .include({ - 'slotsOfAppointment': { - 'include': {'user': true}, - }, - }) - .build(); - - final appointments = await executeQueryAsMaps(appointmentsQuery); + final webinarIds = filteredWebinars.map((w) => w['id'] as String).toList(); + + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput( + webinarId: StringFilter(in_: webinarIds), + ), + include: const AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(user: UserInclude()), + ), + ); final appointmentLookup = >{}; for (final a in appointments) { @@ -966,8 +886,7 @@ class AppointmentRepository extends BaseRepository { 'planCurrency': plan?['priceCurrency'], 'planDuration': plan?['durationInHours'], 'maxParticipants': plan?['maxParticipants'], - if (slots != null && slots.isNotEmpty) - 'slots': _formatSlots(slots), + if (slots != null && slots.isNotEmpty) 'slots': _formatSlots(slots), 'participants': participantData['participants'], 'participantCount': participantData['participantCount'], }); @@ -982,23 +901,22 @@ class AppointmentRepository extends BaseRepository { String? status, }) async { // Get all class plans for this consultant - final plansQuery = JsonQueryBuilder() - .model('ClassPlan') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}) - .select({ - 'id': true, - 'title': true, - 'price': true, - 'priceCurrency': true, - 'totalSessions': true, - 'sessionDurationInHours': true, - 'durationInMonths': true, - 'maxParticipants': true, - }) - .build(); - - final plans = await executeQueryAsMaps(plansQuery); + final plans = await _prisma.classPlan.findManyProjected( + where: ClassPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [ + ClassPlanScalarField.id, + ClassPlanScalarField.title, + ClassPlanScalarField.price, + ClassPlanScalarField.priceCurrency, + ClassPlanScalarField.totalSessions, + ClassPlanScalarField.sessionDurationInHours, + ClassPlanScalarField.durationInMonths, + ClassPlanScalarField.maxParticipants, + ], + ); + if (plans.isEmpty) return []; final planIds = plans.map((p) => p['id'] as String).toList(); @@ -1008,15 +926,12 @@ class AppointmentRepository extends BaseRepository { } // Get class records for those plans - final classesQuery = JsonQueryBuilder() - .model('Class') - .action(QueryAction.findMany) - .where({ - 'classPlanId': {'in': planIds}, - }) - .build(); - - final classes = await executeQueryAsMaps(classesQuery); + final classes = await _prisma.classModel.findManyProjected( + where: ClassModelWhereInput( + classPlanId: StringFilter(in_: planIds), + ), + ); + if (classes.isEmpty) return []; // Apply status filter if provided @@ -1030,32 +945,23 @@ class AppointmentRepository extends BaseRepository { if (filteredClasses.isEmpty) return []; // Batch fetch appointments with slots + enrolled users for all classes - final classIds = - filteredClasses.map((c) => c['id'] as String).toList(); - - final appointmentsQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'classId': {'in': classIds}, - }) - .include({ - 'slotsOfAppointment': { - 'include': {'user': true}, - }, - }) - .build(); - - final appointments = await executeQueryAsMaps(appointmentsQuery); + final classIds = filteredClasses.map((c) => c['id'] as String).toList(); + + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput( + classId: StringFilter(in_: classIds), + ), + include: const AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(user: UserInclude()), + ), + ); + // Classes can have multiple appointments; group by classId - final appointmentsByClass = - >>{}; + final appointmentsByClass = >>{}; for (final a in appointments) { final cId = a['classId'] as String?; if (cId != null) { - appointmentsByClass - .putIfAbsent(cId, () => []) - .add(a); + appointmentsByClass.putIfAbsent(cId, () => []).add(a); } } @@ -1065,8 +971,7 @@ class AppointmentRepository extends BaseRepository { final classId = c['id'] as String; final planId = c['classPlanId'] as String?; final plan = planId != null ? planLookup[planId] : null; - final classAppointments = - appointmentsByClass[classId] ?? []; + final classAppointments = appointmentsByClass[classId] ?? []; // Collect all slots from all appointments final allSlots = []; @@ -1085,29 +990,23 @@ class AppointmentRepository extends BaseRepository { 'status': _mapClassStatusToRequestStatus( c['status'] as String?, ), - 'appointmentId': classAppointments.isNotEmpty - ? classAppointments.first['id'] - : null, + 'appointmentId': + classAppointments.isNotEmpty ? classAppointments.first['id'] : null, 'createdAt': classAppointments.isNotEmpty - ? (classAppointments.first['createdAt'] ?? - c['createdAt']) + ? (classAppointments.first['createdAt'] ?? c['createdAt']) : c['createdAt'], - 'schedulingPeriodStartsAt': - c['schedulingPeriodStartsAt'], - 'schedulingPeriodEndsAt': - c['schedulingPeriodEndsAt'], + 'schedulingPeriodStartsAt': c['schedulingPeriodStartsAt'], + 'schedulingPeriodEndsAt': c['schedulingPeriodEndsAt'], 'schedulingTimezone': c['schedulingTimezone'], 'planId': plan?['id'], 'planTitle': plan?['title'], 'planPrice': plan?['price'], 'planCurrency': plan?['priceCurrency'], 'totalSessions': plan?['totalSessions'], - 'sessionDurationInHours': - plan?['sessionDurationInHours'], + 'sessionDurationInHours': plan?['sessionDurationInHours'], 'durationInMonths': plan?['durationInMonths'], 'maxParticipants': plan?['maxParticipants'], - if (allSlots.isNotEmpty) - 'slots': _formatSlots(allSlots), + if (allSlots.isNotEmpty) 'slots': _formatSlots(allSlots), 'participants': participantData['participants'], 'participantCount': participantData['participantCount'], }); @@ -1121,29 +1020,27 @@ class AppointmentRepository extends BaseRepository { required String consultantProfileId, String? status, }) async { - final where = { - 'consultantProfileId': consultantProfileId, - }; + TrialSessionStatusFilter? statusFilter; if (status != null) { final trialStatus = _mapRequestStatusToTrialStatus(status); if (trialStatus == null) return []; - where['status'] = trialStatus; + statusFilter = TrialSessionStatusFilter( + equals: _trialSessionStatusFromString(trialStatus), + ); } - final trialsQuery = JsonQueryBuilder() - .model('TrialSession') - .action(QueryAction.findMany) - .where(where) - .include({ - 'subscriptionPlan': true, - 'consulteeProfile': { - 'include': {'user': true}, - }, - }) - .orderBy({'createdAt': 'desc'}) - .build(); - - final trials = await executeQueryAsMaps(trialsQuery); + final trials = await _prisma.trialSession.findManyProjected( + where: TrialSessionWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + status: statusFilter, + ), + include: const TrialSessionInclude( + subscriptionPlan: SubscriptionPlanInclude(), + consulteeProfile: ConsulteeProfileInclude(user: UserInclude()), + ), + orderBy: {'createdAt': 'desc'}, + ); + if (trials.isEmpty) return []; // Batch fetch appointments with slots @@ -1155,15 +1052,14 @@ class AppointmentRepository extends BaseRepository { final appointmentLookup = >{}; if (appointmentIds.isNotEmpty) { - final appointmentsQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'id': {'in': appointmentIds}, - }) - .include({'slotsOfAppointment': true}) - .build(); - final appointments = await executeQueryAsMaps(appointmentsQuery); + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput( + id: StringFilter(in_: appointmentIds), + ), + include: const AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(), + ), + ); for (final a in appointments) { appointmentLookup[a['id'] as String] = a; } @@ -1172,10 +1068,8 @@ class AppointmentRepository extends BaseRepository { final bookings = >[]; for (final t in trials) { final plan = t['subscriptionPlan'] as Map?; - final consulteeProfile = - t['consulteeProfile'] as Map?; - final consulteeUser = - consulteeProfile?['user'] as Map?; + final consulteeProfile = t['consulteeProfile'] as Map?; + final consulteeUser = consulteeProfile?['user'] as Map?; final appointmentId = t['appointmentId'] as String?; final appointment = appointmentId != null ? appointmentLookup[appointmentId] : null; @@ -1191,9 +1085,8 @@ class AppointmentRepository extends BaseRepository { 'planTitle': plan?['title'], 'planPrice': 0, 'planCurrency': plan?['priceCurrency'] ?? 'INR', - 'planDuration': - (plan?['freeTrialDurationMinutes'] as num?)?.toDouble(), - 'freeTrialDurationMinutes': plan?['freeTrialDurationMinutes'], + 'planDuration': (plan?['trialDurationMinutes'] as num?)?.toDouble(), + 'freeTrialDurationMinutes': plan?['trialDurationMinutes'], 'consulteeProfileId': consulteeProfile?['id'], 'consulteeUserId': consulteeUser?['id'], 'consulteeName': consulteeUser?['name'], @@ -1211,21 +1104,21 @@ class AppointmentRepository extends BaseRepository { required String consulteeProfileId, String? status, }) async { - final where = { - 'requestedById': consulteeProfileId, - }; - if (status != null) { - where['requestStatus'] = status; - } - - final consultationsQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findMany) - .where(where) - .include({'consultationPlan': true}).orderBy( - {'createdAt': 'desc'}).build(); + final consultations = await _prisma.consultation.findManyProjected( + where: ConsultationWhereInput( + requestedById: StringFilter(equals: consulteeProfileId), + status: status != null + ? AppointmentStatusFilter( + equals: _appointmentStatusFromString(status), + ) + : null, + ), + include: const ConsultationInclude( + consultationPlan: ConsultationPlanInclude(), + ), + orderBy: {'createdAt': 'desc'}, + ); - final consultations = await executeQueryAsMaps(consultationsQuery); if (consultations.isEmpty) return []; // Collect all consultant profile IDs for batch fetch @@ -1245,13 +1138,14 @@ class AppointmentRepository extends BaseRepository { await _batchFetchConsultantInfo(consultantProfileIds); // Batch fetch appointments with slots - final appointmentsQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'consultationId': {'in': consultationIds} - }).include({'slotsOfAppointment': true}).build(); - final appointments = await executeQueryAsMaps(appointmentsQuery); + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput( + consultationId: StringFilter(in_: consultationIds), + ), + include: const AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(), + ), + ); final appointmentLookup = >{}; for (final a in appointments) { final consultationId = a['consultationId'] as String?; @@ -1296,21 +1190,21 @@ class AppointmentRepository extends BaseRepository { required String consulteeProfileId, String? status, }) async { - final where = { - 'requestedById': consulteeProfileId, - }; - if (status != null) { - where['requestStatus'] = status; - } - - final subscriptionsQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findMany) - .where(where) - .include({'subscriptionPlan': true}).orderBy( - {'createdAt': 'desc'}).build(); + final subscriptions = await _prisma.subscription.findManyProjected( + where: SubscriptionWhereInput( + requestedById: StringFilter(equals: consulteeProfileId), + status: status != null + ? AppointmentStatusFilter( + equals: _appointmentStatusFromString(status), + ) + : null, + ), + include: const SubscriptionInclude( + subscriptionPlan: SubscriptionPlanInclude(), + ), + orderBy: {'createdAt': 'desc'}, + ); - final subscriptions = await executeQueryAsMaps(subscriptionsQuery); if (subscriptions.isEmpty) return []; // Collect all consultant profile IDs for batch fetch @@ -1368,23 +1262,25 @@ class AppointmentRepository extends BaseRepository { String? status, }) async { // Query appointments where user is enrolled via SlotOfAppointment M2M relation - // Using nested includes (v0.3.8 fix) - webinarPlan is properly nested in webinar - final appointmentsQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'appointmentType': 'WEBINAR', - 'slotsOfAppointment': FilterOperators.some({ - 'user': FilterOperators.some({'id': userId}), - }), - }).include({ - 'webinar': { - 'include': {'webinarPlan': true}, - }, - 'slotsOfAppointment': true, - }).build(); + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput( + appointmentType: const AppointmentsTypeFilter( + equals: AppointmentsType.webinar, + ), + slotsOfAppointment: SlotOfAppointmentListRelationFilter( + some: SlotOfAppointmentWhereInput( + user: UserListRelationFilter( + some: UserWhereInput(id: StringFilter(equals: userId)), + ), + ), + ), + ), + include: const AppointmentInclude( + webinar: WebinarInclude(webinarPlan: WebinarPlanInclude()), + slotsOfAppointment: SlotOfAppointmentInclude(), + ), + ); - final appointments = await executeQueryAsMaps(appointmentsQuery); if (appointments.isEmpty) return []; // Apply status filter if provided @@ -1402,7 +1298,6 @@ class AppointmentRepository extends BaseRepository { final consultantProfileIds = []; for (final apt in filteredAppointments) { final webinar = apt['webinar'] as Map?; - // Access webinarPlan directly from nested include (v0.3.8 fix) final plan = webinar?['webinarPlan'] as Map?; final id = plan?['consultantProfileId'] as String?; if (id != null && !consultantProfileIds.contains(id)) { @@ -1419,7 +1314,6 @@ class AppointmentRepository extends BaseRepository { if (webinar == null) continue; final webinarStatus = webinar['status'] as String?; - // Access webinarPlan directly from nested include (v0.3.8 fix) final plan = webinar['webinarPlan'] as Map?; final consultantProfileId = plan?['consultantProfileId'] as String?; final consultantInfo = @@ -1456,29 +1350,31 @@ class AppointmentRepository extends BaseRepository { String? status, }) async { // Query appointments where user is enrolled via SlotOfAppointment M2M - // Using nested includes (v0.3.8 fix) - classPlan is properly nested in class - final appointmentsQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'appointmentType': 'CLASS', - 'slotsOfAppointment': FilterOperators.some({ - 'user': FilterOperators.some({'id': userId}), - }), - }).include({ - 'class': { - 'include': {'classPlan': true}, - }, - 'slotsOfAppointment': true, - }).build(); + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput( + appointmentType: const AppointmentsTypeFilter( + equals: AppointmentsType.classValue, + ), + slotsOfAppointment: SlotOfAppointmentListRelationFilter( + some: SlotOfAppointmentWhereInput( + user: UserListRelationFilter( + some: UserWhereInput(id: StringFilter(equals: userId)), + ), + ), + ), + ), + include: const AppointmentInclude( + classRef: ClassModelInclude(classPlan: ClassPlanInclude()), + slotsOfAppointment: SlotOfAppointmentInclude(), + ), + ); - final appointments = await executeQueryAsMaps(appointmentsQuery); if (appointments.isEmpty) return []; // Apply status filter if provided final filteredAppointments = status != null ? appointments.where((apt) { - final classRecord = apt['class'] as Map?; + final classRecord = apt['classRef'] as Map?; final classStatus = classRecord?['status'] as String?; return _matchesClassStatus(classStatus, status); }).toList() @@ -1489,8 +1385,7 @@ class AppointmentRepository extends BaseRepository { // Collect consultant profile IDs for batch fetch final consultantProfileIds = []; for (final apt in filteredAppointments) { - final classRecord = apt['class'] as Map?; - // Access classPlan directly from nested include (v0.3.8 fix) + final classRecord = apt['classRef'] as Map?; final plan = classRecord?['classPlan'] as Map?; final id = plan?['consultantProfileId'] as String?; if (id != null && !consultantProfileIds.contains(id)) { @@ -1503,11 +1398,10 @@ class AppointmentRepository extends BaseRepository { // Build bookings final bookings = >[]; for (final apt in filteredAppointments) { - final classRecord = apt['class'] as Map?; + final classRecord = apt['classRef'] as Map?; if (classRecord == null) continue; final classStatus = classRecord['status'] as String?; - // Access classPlan directly from nested include (v0.3.8 fix) final plan = classRecord['classPlan'] as Map?; final consultantProfileId = plan?['consultantProfileId'] as String?; final consultantInfo = @@ -1545,25 +1439,27 @@ class AppointmentRepository extends BaseRepository { required String consulteeProfileId, String? status, }) async { - final where = { - 'consulteeProfileId': consulteeProfileId, - }; + TrialSessionStatusFilter? statusFilter; if (status != null) { // Map RequestStatus to TrialSessionStatus for filtering final trialStatus = _mapRequestStatusToTrialStatus(status); if (trialStatus == null) return []; // No matching trial status - where['status'] = trialStatus; + statusFilter = TrialSessionStatusFilter( + equals: _trialSessionStatusFromString(trialStatus), + ); } - final trialsQuery = JsonQueryBuilder() - .model('TrialSession') - .action(QueryAction.findMany) - .where(where) - .include({'subscriptionPlan': true}) - .orderBy({'createdAt': 'desc'}) - .build(); + final trials = await _prisma.trialSession.findManyProjected( + where: TrialSessionWhereInput( + consulteeProfileId: StringFilter(equals: consulteeProfileId), + status: statusFilter, + ), + include: const TrialSessionInclude( + subscriptionPlan: SubscriptionPlanInclude(), + ), + orderBy: {'createdAt': 'desc'}, + ); - final trials = await executeQueryAsMaps(trialsQuery); if (trials.isEmpty) return []; // Collect consultant profile IDs for batch fetch @@ -1588,15 +1484,14 @@ class AppointmentRepository extends BaseRepository { final appointmentLookup = >{}; if (appointmentIds.isNotEmpty) { - final appointmentsQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'id': {'in': appointmentIds}, - }) - .include({'slotsOfAppointment': true}) - .build(); - final appointments = await executeQueryAsMaps(appointmentsQuery); + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput( + id: StringFilter(in_: appointmentIds), + ), + include: const AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(), + ), + ); for (final a in appointments) { final appointmentId = a['id'] as String; appointmentLookup[appointmentId] = a; @@ -1627,9 +1522,8 @@ class AppointmentRepository extends BaseRepository { 'planTitle': plan?['title'], 'planPrice': 0, // Trials are free 'planCurrency': plan?['priceCurrency'] ?? 'INR', - 'planDuration': (plan?['freeTrialDurationMinutes'] as num?) - ?.toDouble(), - 'freeTrialDurationMinutes': plan?['freeTrialDurationMinutes'], + 'planDuration': (plan?['trialDurationMinutes'] as num?)?.toDouble(), + 'freeTrialDurationMinutes': plan?['trialDurationMinutes'], ...consultantInfo, if (appointmentId != null) 'appointmentId': appointmentId, if (slots != null && slots.isNotEmpty) 'slots': _formatSlots(slots), @@ -1691,16 +1585,12 @@ class AppointmentRepository extends BaseRepository { // Remove duplicates and nulls final uniqueIds = consultantProfileIds.toSet().toList(); - final profilesQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findMany) - .where({ - 'id': {'in': uniqueIds}, - }) - .include({'user': true}) - .build(); - - final profiles = await executeQueryAsMaps(profilesQuery); + final profiles = await _prisma.consultantProfile.findManyProjected( + where: ConsultantProfileWhereInput( + id: StringFilter(in_: uniqueIds), + ), + include: const ConsultantProfileInclude(user: UserInclude()), + ); // Build lookup map final result = >{}; @@ -1862,43 +1752,34 @@ class AppointmentRepository extends BaseRepository { Future> _getConsultationById(String id) async { // Wrap in transaction for consistent reads - return executeInTransaction((txn) async { - // Single query with nested includes (nested includes fixed in v0.3.8+) - final query = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findUnique) - .where({'id': id}) - .include({ - 'consultationPlan': { - 'include': { - 'consultantProfile': { - 'include': {'user': true}, - }, - }, - }, - }) - .build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); + return _prisma.$transaction((tx) async { + // Single query with nested includes + final result = await tx.consultation.findFirstProjected( + where: ConsultationWhereInput(id: StringFilter(equals: id)), + include: const ConsultationInclude( + consultationPlan: ConsultationPlanInclude( + consultantProfile: ConsultantProfileInclude(user: UserInclude()), + ), + ), + ); if (result == null) { throw Exception('Consultation not found'); } final plan = result['consultationPlan'] as Map?; - final profile = - plan?['consultantProfile'] as Map?; + final profile = plan?['consultantProfile'] as Map?; final user = profile?['user'] as Map?; // Fetch consultee (requestedBy) profile and user final consulteeInfo = await _fetchConsulteeInfo( result['requestedById'] as String?, - txn: txn, + client: tx, ); final consulteeProfile = consulteeInfo.profile; final consulteeUser = consulteeInfo.user; - final booking = { + final booking = { 'id': result['id'], 'bookingType': 'CONSULTATION', 'status': result['requestStatus'], @@ -1930,37 +1811,33 @@ class AppointmentRepository extends BaseRepository { 'cancelledBy': result['cancelledBy'], }; - // Get appointment with slots via nested include - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findFirst) - .where({'consultationId': id}) - .include({ - 'slotsOfAppointment': { - 'orderBy': {'startsAt': 'asc'}, - }, - }) - .build(); - - final appointment = - await executeQueryAsSingleMap(appointmentQuery, txn: txn); + // Get appointment, then its slots ordered by start time + final appointment = await tx.appointment.findFirstProjected( + where: AppointmentWhereInput( + consultationId: StringFilter(equals: id), + ), + ); if (appointment != null) { booking['appointmentId'] = appointment['id']; - final slots = appointment['slotsOfAppointment'] as List? ?? []; + final slots = await tx.slotOfAppointment.findManyProjected( + where: SlotOfAppointmentWhereInput( + appointmentId: StringFilter( + equals: appointment['id'] as String, + ), + ), + orderBy: {'startsAt': 'asc'}, + ); if (slots.isNotEmpty) { - booking['slots'] = slots - .map((s) { - final slot = s as Map; - return { - 'id': slot['id'], - 'startsAt': slot['startsAt'], - 'endsAt': slot['endsAt'], - 'isTentative': slot['isTentative'], - }; - }) - .toList(); + booking['slots'] = slots.map((slot) { + return { + 'id': slot['id'], + 'startsAt': slot['startsAt'], + 'endsAt': slot['endsAt'], + 'isTentative': slot['isTentative'], + }; + }).toList(); } } @@ -1970,43 +1847,34 @@ class AppointmentRepository extends BaseRepository { Future> _getSubscriptionById(String id) async { // Wrap in transaction for consistent reads - return executeInTransaction((txn) async { - // Single query with nested includes (nested includes fixed in v0.3.8+) - final query = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findUnique) - .where({'id': id}) - .include({ - 'subscriptionPlan': { - 'include': { - 'consultantProfile': { - 'include': {'user': true}, - }, - }, - }, - }) - .build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); + return _prisma.$transaction((tx) async { + // Single query with nested includes + final result = await tx.subscription.findFirstProjected( + where: SubscriptionWhereInput(id: StringFilter(equals: id)), + include: const SubscriptionInclude( + subscriptionPlan: SubscriptionPlanInclude( + consultantProfile: ConsultantProfileInclude(user: UserInclude()), + ), + ), + ); if (result == null) { throw Exception('Subscription not found'); } final plan = result['subscriptionPlan'] as Map?; - final profile = - plan?['consultantProfile'] as Map?; + final profile = plan?['consultantProfile'] as Map?; final user = profile?['user'] as Map?; // Fetch consultee (requestedBy) profile and user final consulteeInfo = await _fetchConsulteeInfo( result['requestedById'] as String?, - txn: txn, + client: tx, ); final consulteeProfile = consulteeInfo.profile; final consulteeUser = consulteeInfo.user; - final booking = { + final booking = { 'id': result['id'], 'bookingType': 'SUBSCRIPTION', 'status': result['requestStatus'], @@ -2051,14 +1919,12 @@ class AppointmentRepository extends BaseRepository { Future> _getWebinarById(String id) async { // Wrap in transaction for consistent reads - return executeInTransaction((txn) async { + return _prisma.$transaction((tx) async { // Get webinar with plan - final query = JsonQueryBuilder() - .model('Webinar') - .action(QueryAction.findUnique) - .where({'id': id}).include({'webinarPlan': true}).build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); + final result = await tx.webinar.findFirstProjected( + where: WebinarWhereInput(id: StringFilter(equals: id)), + include: const WebinarInclude(webinarPlan: WebinarPlanInclude()), + ); if (result == null) { throw Exception('Webinar not found'); @@ -2073,11 +1939,12 @@ class AppointmentRepository extends BaseRepository { Map? profile; Map? user; if (consultantProfileId != null) { - final profileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findUnique) - .where({'id': consultantProfileId}).include({'user': true}).build(); - profile = await executeQueryAsSingleMap(profileQuery, txn: txn); + profile = await tx.consultantProfile.findFirstProjected( + where: ConsultantProfileWhereInput( + id: StringFilter(equals: consultantProfileId), + ), + include: const ConsultantProfileInclude(user: UserInclude()), + ); user = profile?['user'] as Map?; if (user == null && profile != null) { @@ -2124,27 +1991,23 @@ class AppointmentRepository extends BaseRepository { }; // Get appointment for this webinar - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findFirst) - .where({'webinarId': id}).build(); - - final appointment = - await executeQueryAsSingleMap(appointmentQuery, txn: txn); + final appointment = await tx.appointment.findFirstProjected( + where: AppointmentWhereInput(webinarId: StringFilter(equals: id)), + ); if (appointment != null) { booking['appointmentId'] = appointment['id']; // Fetch slots with users for participant extraction - final slotsQuery = JsonQueryBuilder() - .model('SlotOfAppointment') - .action(QueryAction.findMany) - .where({'appointmentId': appointment['id']}) - .include({'user': true}) - .orderBy({'startsAt': 'asc'}) - .build(); - - final slots = await executeQueryAsMaps(slotsQuery, txn: txn); + final slots = await tx.slotOfAppointment.findManyProjected( + where: SlotOfAppointmentWhereInput( + appointmentId: StringFilter( + equals: appointment['id'] as String, + ), + ), + include: const SlotOfAppointmentInclude(user: UserInclude()), + orderBy: {'startsAt': 'asc'}, + ); if (slots.isNotEmpty) { booking['slots'] = slots .map((s) => { @@ -2158,8 +2021,7 @@ class AppointmentRepository extends BaseRepository { // Extract participants from slots final participantData = _extractParticipants(slots); booking['participants'] = participantData['participants']; - booking['participantCount'] = - participantData['participantCount']; + booking['participantCount'] = participantData['participantCount']; } } @@ -2169,14 +2031,12 @@ class AppointmentRepository extends BaseRepository { Future> _getClassById(String id) async { // Wrap in transaction for consistent reads - return executeInTransaction((txn) async { + return _prisma.$transaction((tx) async { // Get class with plan - final query = JsonQueryBuilder() - .model('Class') - .action(QueryAction.findUnique) - .where({'id': id}).include({'classPlan': true}).build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); + final result = await tx.classModel.findFirstProjected( + where: ClassModelWhereInput(id: StringFilter(equals: id)), + include: const ClassModelInclude(classPlan: ClassPlanInclude()), + ); if (result == null) { throw Exception('Class not found'); @@ -2191,11 +2051,12 @@ class AppointmentRepository extends BaseRepository { Map? profile; Map? user; if (consultantProfileId != null) { - final profileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findUnique) - .where({'id': consultantProfileId}).include({'user': true}).build(); - profile = await executeQueryAsSingleMap(profileQuery, txn: txn); + profile = await tx.consultantProfile.findFirstProjected( + where: ConsultantProfileWhereInput( + id: StringFilter(equals: consultantProfileId), + ), + include: const ConsultantProfileInclude(user: UserInclude()), + ); user = profile?['user'] as Map?; if (user == null && profile != null) { @@ -2251,12 +2112,9 @@ class AppointmentRepository extends BaseRepository { }; // Get appointments for this class (can have multiple) - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({'classId': id}).build(); - - final appointments = await executeQueryAsMaps(appointmentQuery, txn: txn); + final appointments = await tx.appointment.findManyProjected( + where: AppointmentWhereInput(classId: StringFilter(equals: id)), + ); if (appointments.isNotEmpty) { // Use first appointment for now (most common case) @@ -2265,15 +2123,13 @@ class AppointmentRepository extends BaseRepository { // Fetch all slots from all appointments (with users) final allSlots = >[]; for (final apt in appointments) { - final slotsQuery = JsonQueryBuilder() - .model('SlotOfAppointment') - .action(QueryAction.findMany) - .where({'appointmentId': apt['id']}) - .include({'user': true}) - .orderBy({'startsAt': 'asc'}) - .build(); - - final slots = await executeQueryAsMaps(slotsQuery, txn: txn); + final slots = await tx.slotOfAppointment.findManyProjected( + where: SlotOfAppointmentWhereInput( + appointmentId: StringFilter(equals: apt['id'] as String), + ), + include: const SlotOfAppointmentInclude(user: UserInclude()), + orderBy: {'startsAt': 'asc'}, + ); allSlots.addAll(slots); } @@ -2303,8 +2159,7 @@ class AppointmentRepository extends BaseRepository { // Extract participants from slots final participantData = _extractParticipants(allSlots); booking['participants'] = participantData['participants']; - booking['participantCount'] = - participantData['participantCount']; + booking['participantCount'] = participantData['participantCount']; } } @@ -2314,16 +2169,14 @@ class AppointmentRepository extends BaseRepository { Future> _getTrialById(String id) async { // Wrap in transaction for consistent reads - return executeInTransaction((txn) async { + return _prisma.$transaction((tx) async { // Get trial session with subscription plan - final query = JsonQueryBuilder() - .model('TrialSession') - .action(QueryAction.findUnique) - .where({'id': id}) - .include({'subscriptionPlan': true}) - .build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); + final result = await tx.trialSession.findFirstProjected( + where: TrialSessionWhereInput(id: StringFilter(equals: id)), + include: const TrialSessionInclude( + subscriptionPlan: SubscriptionPlanInclude(), + ), + ); if (result == null) { throw Exception('Trial session not found'); @@ -2336,15 +2189,11 @@ class AppointmentRepository extends BaseRepository { Map? profile; Map? user; if (consultantProfileId != null) { - final profileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findUnique) - .where({'id': consultantProfileId}) - .include({'user': true}) - .build(); - profile = await executeQueryAsSingleMap( - profileQuery, - txn: txn, + profile = await tx.consultantProfile.findFirstProjected( + where: ConsultantProfileWhereInput( + id: StringFilter(equals: consultantProfileId), + ), + include: const ConsultantProfileInclude(user: UserInclude()), ); user = profile?['user'] as Map?; @@ -2364,7 +2213,7 @@ class AppointmentRepository extends BaseRepository { // Fetch consultee profile and user final consulteeInfo = await _fetchConsulteeInfo( result['consulteeProfileId'] as String?, - txn: txn, + client: tx, ); final consulteeProfile = consulteeInfo.profile; final consulteeUser = consulteeInfo.user; @@ -2382,9 +2231,8 @@ class AppointmentRepository extends BaseRepository { 'planTitle': plan?['title'], 'planPrice': 0, // Trials are free 'planCurrency': plan?['priceCurrency'] ?? 'INR', - 'planDuration': plan?['freeTrialDurationMinutes'], - 'freeTrialDurationMinutes': - plan?['freeTrialDurationMinutes'], + 'planDuration': plan?['trialDurationMinutes'], + 'freeTrialDurationMinutes': plan?['trialDurationMinutes'], 'consultantProfileId': profile?['id'], 'consultantUserId': user?['id'], 'consultantName': user?['name'], @@ -2396,22 +2244,16 @@ class AppointmentRepository extends BaseRepository { }; // Get linked appointment if exists - final appointmentId = - result['appointmentId'] as String?; + final appointmentId = result['appointmentId'] as String?; if (appointmentId != null) { booking['appointmentId'] = appointmentId; // Fetch slots - final slotsQuery = JsonQueryBuilder() - .model('SlotOfAppointment') - .action(QueryAction.findMany) - .where({'appointmentId': appointmentId}) - .orderBy({'startsAt': 'asc'}) - .build(); - - final slots = await executeQueryAsMaps( - slotsQuery, - txn: txn, + final slots = await tx.slotOfAppointment.findManyProjected( + where: SlotOfAppointmentWhereInput( + appointmentId: StringFilter(equals: appointmentId), + ), + orderBy: {'startsAt': 'asc'}, ); if (slots.isNotEmpty) { booking['slots'] = slots @@ -2441,188 +2283,108 @@ class AppointmentRepository extends BaseRepository { String? reason, }) async { // Get consultee profile ID - final profileQuery = JsonQueryBuilder() - .model('ConsulteeProfile') - .action(QueryAction.findFirst) - .where({'userId': userId}).build(); - - final profile = await executeQueryAsSingleMap(profileQuery); + final profile = await _prisma.consulteeProfile.findFirstProjected( + where: ConsulteeProfileWhereInput( + userId: StringFilter(equals: userId), + ), + ); if (profile == null) { throw Exception('User profile not found'); } final consulteeProfileId = profile['id'] as String; - final now = nowIso8601; + final now = DateTime.now().toUtc(); + // Guard external enum wire value: an unknown reason must surface as a + // validation error (ArgumentError -> 400), not a StateError -> 500. + CancellationReason? cancellationReason; + if (reason != null) { + final matches = + CancellationReason.values.where((e) => e.toJson() == reason); + if (matches.isEmpty) { + throw ArgumentError.value( + reason, + 'reason', + 'Unsupported cancellation reason. Allowed: ' + '${CancellationReason.values.map((e) => e.toJson()).join(', ')}', + ); + } + cancellationReason = matches.first; + } // Use explicit model queries instead of dynamic table names if (type == 'CONSULTATION') { // Verify ownership - final verifyQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findFirst) - .where({ - 'id': id, - 'requestedById': consulteeProfileId, - }).build(); - - final booking = await executeQueryAsSingleMap(verifyQuery); + final booking = await _prisma.consultation.findFirstProjected( + where: ConsultationWhereInput( + id: StringFilter(equals: id), + requestedById: StringFilter(equals: consulteeProfileId), + ), + ); if (booking == null) { throw Exception('Booking not found or you do not have permission'); } - // Update status to cancelled with metadata - final updateQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.update) - .where({'id': id}).data({ - 'requestStatus': 'CANCELLED', - // cancellationReason is an enum column post schema-sync; free-text - // from the client is stored in cancellationNotes instead - 'cancellationReason': 'OTHER', - if (reason != null && reason.isNotEmpty) 'cancellationNotes': reason, - 'cancelledAt': now, - 'cancelledBy': userId, - 'updatedAt': now, - }).build(); - - await executeMutation(updateQuery); + // Update status to cancelled with metadata (updateMany keeps the old + // silent-if-missing semantics; updatedAt is autofilled) + await _prisma.consultation.updateMany( + where: ConsultationWhereInput(id: StringFilter(equals: id)), + data: UpdateConsultationInput( + status: AppointmentStatus.cancelled, + cancellationReason: cancellationReason, + cancelledAt: now, + cancelledBy: consulteeProfileId, + ), + ); } else if (type == 'SUBSCRIPTION') { // Verify ownership - final verifyQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findFirst) - .where({ - 'id': id, - 'requestedById': consulteeProfileId, - }).build(); - - final booking = await executeQueryAsSingleMap(verifyQuery); + final booking = await _prisma.subscription.findFirstProjected( + where: SubscriptionWhereInput( + id: StringFilter(equals: id), + requestedById: StringFilter(equals: consulteeProfileId), + ), + ); if (booking == null) { throw Exception('Booking not found or you do not have permission'); } // Update status to cancelled with metadata - final updateQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.update) - .where({'id': id}).data({ - 'requestStatus': 'CANCELLED', - // cancellationReason is an enum column post schema-sync; free-text - // from the client is stored in cancellationNotes instead - 'cancellationReason': 'OTHER', - if (reason != null && reason.isNotEmpty) 'cancellationNotes': reason, - 'cancelledAt': now, - 'cancelledBy': userId, - 'updatedAt': now, - }).build(); - - await executeMutation(updateQuery); + await _prisma.subscription.updateMany( + where: SubscriptionWhereInput(id: StringFilter(equals: id)), + data: UpdateSubscriptionInput( + status: AppointmentStatus.cancelled, + cancellationReason: cancellationReason, + cancelledAt: now, + cancelledBy: consulteeProfileId, + ), + ); } else if (type == 'TRIAL') { // Verify ownership via consultee profile - final verifyQuery = JsonQueryBuilder() - .model('TrialSession') - .action(QueryAction.findFirst) - .where({ - 'id': id, - 'consulteeProfileId': consulteeProfileId, - }).build(); - - final booking = await executeQueryAsSingleMap(verifyQuery); + final booking = await _prisma.trialSession.findFirstProjected( + where: TrialSessionWhereInput( + id: StringFilter(equals: id), + consulteeProfileId: StringFilter(equals: consulteeProfileId), + ), + ); if (booking == null) { throw Exception('Booking not found or you do not have permission'); } // Update status to CANCELLED - final updateQuery = JsonQueryBuilder() - .model('TrialSession') - .action(QueryAction.update) - .where({'id': id}).data({ - 'status': 'CANCELLED', - 'updatedAt': now, - }).build(); - - await executeMutation(updateQuery); + await _prisma.trialSession.updateMany( + where: TrialSessionWhereInput(id: StringFilter(equals: id)), + data: const UpdateTrialSessionInput( + status: TrialSessionStatus.cancelled, + ), + ); } else { throw Exception('Invalid booking type'); } } - /// Respond to a pending booking request (consultant approve/reject). - /// - /// Approval moves the request to APPROVED_PENDING_PAYMENT — the consultee - /// completes payment on the web, which schedules the appointment there. - Future> respondToBookingRequest({ - required String id, - required String type, - required String userId, - required bool approve, - }) async { - // Resolve the consultant profile for authorization - final profileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findFirst) - .where({'userId': userId}).select({'id': true}).build(); - final profile = await executeQueryAsSingleMap(profileQuery); - if (profile == null) { - throw Exception('Booking not found or you do not have permission'); - } - final consultantProfileId = profile['id'] as String; - - if (type != 'CONSULTATION' && type != 'SUBSCRIPTION') { - throw Exception('Invalid booking type'); - } - final isConsultation = type == 'CONSULTATION'; - final model = isConsultation ? 'Consultation' : 'Subscription'; - final planModel = isConsultation ? 'ConsultationPlan' : 'SubscriptionPlan'; - final planIdField = - isConsultation ? 'consultationPlanId' : 'subscriptionPlanId'; - - // The request must exist and still be pending - final bookingQuery = JsonQueryBuilder() - .model(model) - .action(QueryAction.findFirst) - .where({'id': id, 'requestStatus': 'PENDING'}).build(); - final booking = await executeQueryAsSingleMap(bookingQuery); - if (booking == null) { - throw Exception('Booking request not found or no longer pending'); - } - - // The plan must belong to the responding consultant - final planQuery = JsonQueryBuilder() - .model(planModel) - .action(QueryAction.findFirst) - .where({ - 'id': booking[planIdField], - 'consultantProfileId': consultantProfileId, - }).select({'id': true}).build(); - final plan = await executeQueryAsSingleMap(planQuery); - if (plan == null) { - throw Exception('Booking not found or you do not have permission'); - } - - final now = nowIso8601; - final newStatus = approve ? 'APPROVED_PENDING_PAYMENT' : 'REJECTED'; - // Compare-and-set on the PENDING status so two concurrent responses - // can't both win — the second writer matches zero rows. - final updateQuery = JsonQueryBuilder() - .model(model) - .action(QueryAction.update) - .where({'id': id, 'requestStatus': 'PENDING'}).data({ - 'requestStatus': newStatus, - 'updatedAt': now, - }).build(); - final affected = await executeMutationCounted(updateQuery); - if (affected == 0) { - throw Exception('Booking request not found or no longer pending'); - } - - return {'id': id, 'status': newStatus, 'respondedAt': now}; - } - /// Reschedule a booking /// /// Marks slots as tentative and reverts status to PENDING. @@ -2639,12 +2401,11 @@ class AppointmentRepository extends BaseRepository { String? slotId, // For individual session reschedule }) async { // Get consultee profile ID - final profileQuery = JsonQueryBuilder() - .model('ConsulteeProfile') - .action(QueryAction.findFirst) - .where({'userId': userId}).build(); - - final profile = await executeQueryAsSingleMap(profileQuery); + final profile = await _prisma.consulteeProfile.findFirstProjected( + where: ConsulteeProfileWhereInput( + userId: StringFilter(equals: userId), + ), + ); if (profile == null) { throw Exception('User profile not found'); @@ -2657,15 +2418,12 @@ class AppointmentRepository extends BaseRepository { if (type == 'CONSULTATION') { // Verify ownership and get booking - final verifyQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findFirst) - .where({ - 'id': id, - 'requestedById': consulteeProfileId, - }).build(); - - final booking = await executeQueryAsSingleMap(verifyQuery); + final booking = await _prisma.consultation.findFirstProjected( + where: ConsultationWhereInput( + id: StringFilter(equals: id), + requestedById: StringFilter(equals: consulteeProfileId), + ), + ); if (booking == null) { throw Exception('Booking not found or you do not have permission'); @@ -2677,12 +2435,14 @@ class AppointmentRepository extends BaseRepository { } // Get appointment with slots - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findFirst) - .where({'consultationId': id}).include({'slotsOfAppointment': true}).build(); - - final appointment = await executeQueryAsSingleMap(appointmentQuery); + final appointment = await _prisma.appointment.findFirstProjected( + where: AppointmentWhereInput( + consultationId: StringFilter(equals: id), + ), + include: const AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(), + ), + ); if (appointment != null) { final slots = appointment['slotsOfAppointment'] as List?; @@ -2697,29 +2457,22 @@ class AppointmentRepository extends BaseRepository { } // Revert status to PENDING - final now = nowIso8601; - final updateQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.update) - .where({'id': id}).data({ - 'requestStatus': 'PENDING', - 'updatedAt': now, - }).build(); - - await executeMutation(updateQuery); + await _prisma.consultation.updateMany( + where: ConsultationWhereInput(id: StringFilter(equals: id)), + data: const UpdateConsultationInput( + status: AppointmentStatus.pending, + ), + ); return getBookingById(id, type: 'CONSULTATION'); } else if (type == 'SUBSCRIPTION') { // Verify ownership and get booking - final verifyQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findFirst) - .where({ - 'id': id, - 'requestedById': consulteeProfileId, - }).build(); - - final booking = await executeQueryAsSingleMap(verifyQuery); + final booking = await _prisma.subscription.findFirstProjected( + where: SubscriptionWhereInput( + id: StringFilter(equals: id), + requestedById: StringFilter(equals: consulteeProfileId), + ), + ); if (booking == null) { throw Exception('Booking not found or you do not have permission'); @@ -2731,12 +2484,14 @@ class AppointmentRepository extends BaseRepository { } // Get appointment with slots - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findFirst) - .where({'subscriptionId': id}).include({'slotsOfAppointment': true}).build(); - - final appointment = await executeQueryAsSingleMap(appointmentQuery); + final appointment = await _prisma.appointment.findFirstProjected( + where: AppointmentWhereInput( + subscriptionId: StringFilter(equals: id), + ), + include: const AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(), + ), + ); if (appointment != null) { final slots = appointment['slotsOfAppointment'] as List?; @@ -2744,9 +2499,7 @@ class AppointmentRepository extends BaseRepository { if (slotId != null) { // Individual session reschedule final targetSlot = slots.firstWhere( - (s) => - (s as Map)['id'] == - slotId, + (s) => (s as Map)['id'] == slotId, orElse: () => null, ); @@ -2768,16 +2521,12 @@ class AppointmentRepository extends BaseRepository { await _markSlotsAsTentative(appointmentId, null); // Revert status to PENDING for full reschedule - final now = nowIso8601; - final updateQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.update) - .where({'id': id}).data({ - 'requestStatus': 'PENDING', - 'updatedAt': now, - }).build(); - - await executeMutation(updateQuery); + await _prisma.subscription.updateMany( + where: SubscriptionWhereInput(id: StringFilter(equals: id)), + data: const UpdateSubscriptionInput( + status: AppointmentStatus.pending, + ), + ); } } } @@ -2819,30 +2568,24 @@ class AppointmentRepository extends BaseRepository { String appointmentId, String? slotId, ) async { - final now = nowIso8601; - + // updateMany keeps the old silent-if-missing semantics; updatedAt is + // autofilled by the typed layer. if (slotId != null) { // Update specific slot - final updateQuery = JsonQueryBuilder() - .model('SlotOfAppointment') - .action(QueryAction.update) - .where({'id': slotId}).data({ - 'isTentative': true, - 'updatedAt': now, - }).build(); - - await executeMutation(updateQuery); + await _prisma.slotOfAppointment.updateMany( + where: SlotOfAppointmentWhereInput( + id: StringFilter(equals: slotId), + ), + data: const UpdateSlotOfAppointmentInput(isTentative: true), + ); } else { // Update all slots for this appointment - final updateQuery = JsonQueryBuilder() - .model('SlotOfAppointment') - .action(QueryAction.updateMany) - .where({'appointmentId': appointmentId}).data({ - 'isTentative': true, - 'updatedAt': now, - }).build(); - - await executeMutation(updateQuery); + await _prisma.slotOfAppointment.updateMany( + where: SlotOfAppointmentWhereInput( + appointmentId: StringFilter(equals: appointmentId), + ), + data: const UpdateSlotOfAppointmentInput(isTentative: true), + ); } } @@ -2853,91 +2596,86 @@ class AppointmentRepository extends BaseRepository { required String userId, }) async { // First, check consultant access (consultant who owns the plan) - final consultantProfileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findFirst) - .where({'userId': userId}).build(); - final consultantProfile = - await executeQueryAsSingleMap(consultantProfileQuery); + await _prisma.consultantProfile.findFirstProjected( + where: ConsultantProfileWhereInput( + userId: StringFilter(equals: userId), + ), + ); if (consultantProfile != null) { final consultantProfileId = consultantProfile['id'] as String; var isConsultant = false; if (type == 'CONSULTATION') { - final query = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findFirst) - .where({'id': bookingId}) - .include({'consultationPlan': true}) - .build(); - final result = await executeQueryAsSingleMap(query); + final result = await _prisma.consultation.findFirstProjected( + where: ConsultationWhereInput( + id: StringFilter(equals: bookingId), + ), + include: const ConsultationInclude( + consultationPlan: ConsultationPlanInclude(), + ), + ); if (result != null) { final plan = result['consultationPlan'] as Map?; - final planConsultantId = - plan?['consultantProfileId'] as String? ?? - result['consultationPlanConsultantProfileId'] as String?; + final planConsultantId = plan?['consultantProfileId'] as String? ?? + result['consultationPlanConsultantProfileId'] as String?; isConsultant = planConsultantId == consultantProfileId; } } else if (type == 'SUBSCRIPTION') { - final query = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findFirst) - .where({'id': bookingId}) - .include({'subscriptionPlan': true}) - .build(); - final result = await executeQueryAsSingleMap(query); + final result = await _prisma.subscription.findFirstProjected( + where: SubscriptionWhereInput( + id: StringFilter(equals: bookingId), + ), + include: const SubscriptionInclude( + subscriptionPlan: SubscriptionPlanInclude(), + ), + ); if (result != null) { final plan = result['subscriptionPlan'] as Map?; - final planConsultantId = - plan?['consultantProfileId'] as String? ?? - result['subscriptionPlanConsultantProfileId'] as String?; + final planConsultantId = plan?['consultantProfileId'] as String? ?? + result['subscriptionPlanConsultantProfileId'] as String?; isConsultant = planConsultantId == consultantProfileId; } } else if (type == 'WEBINAR') { - final query = JsonQueryBuilder() - .model('Webinar') - .action(QueryAction.findFirst) - .where({'id': bookingId}) - .include({'webinarPlan': true}) - .build(); - final result = await executeQueryAsSingleMap(query); + final result = await _prisma.webinar.findFirstProjected( + where: WebinarWhereInput( + id: StringFilter(equals: bookingId), + ), + include: const WebinarInclude(webinarPlan: WebinarPlanInclude()), + ); if (result != null) { final plan = result['webinarPlan'] as Map?; - final planConsultantId = - plan?['consultantProfileId'] as String? ?? - result['webinarPlanConsultantProfileId'] as String?; + final planConsultantId = plan?['consultantProfileId'] as String? ?? + result['webinarPlanConsultantProfileId'] as String?; isConsultant = planConsultantId == consultantProfileId; } } else if (type == 'CLASS') { - final query = JsonQueryBuilder() - .model('Class') - .action(QueryAction.findFirst) - .where({'id': bookingId}) - .include({'classPlan': true}) - .build(); - final result = await executeQueryAsSingleMap(query); + final result = await _prisma.classModel.findFirstProjected( + where: ClassModelWhereInput( + id: StringFilter(equals: bookingId), + ), + include: const ClassModelInclude(classPlan: ClassPlanInclude()), + ); if (result != null) { final plan = result['classPlan'] as Map?; - final planConsultantId = - plan?['consultantProfileId'] as String? ?? - result['classPlanConsultantProfileId'] as String?; + final planConsultantId = plan?['consultantProfileId'] as String? ?? + result['classPlanConsultantProfileId'] as String?; isConsultant = planConsultantId == consultantProfileId; } } else if (type == 'TRIAL') { - final query = JsonQueryBuilder() - .model('TrialSession') - .action(QueryAction.findFirst) - .where({'id': bookingId}) - .include({'subscriptionPlan': true}) - .build(); - final result = await executeQueryAsSingleMap(query); + final result = await _prisma.trialSession.findFirstProjected( + where: TrialSessionWhereInput( + id: StringFilter(equals: bookingId), + ), + include: const TrialSessionInclude( + subscriptionPlan: SubscriptionPlanInclude(), + ), + ); if (result != null) { final plan = result['subscriptionPlan'] as Map?; - final planConsultantId = - plan?['consultantProfileId'] as String? ?? - result['subscriptionPlanConsultantProfileId'] as String?; + final planConsultantId = plan?['consultantProfileId'] as String? ?? + result['subscriptionPlanConsultantProfileId'] as String?; isConsultant = planConsultantId == consultantProfileId; } } @@ -2948,49 +2686,39 @@ class AppointmentRepository extends BaseRepository { // For CONSULTATION, SUBSCRIPTION, and TRIAL, check via consultee profile if (type == 'CONSULTATION' || type == 'SUBSCRIPTION' || type == 'TRIAL') { // Get consultee profile ID - final profileQuery = JsonQueryBuilder() - .model('ConsulteeProfile') - .action(QueryAction.findFirst) - .where({'userId': userId}).build(); - - final profile = await executeQueryAsSingleMap(profileQuery); + final profile = await _prisma.consulteeProfile.findFirstProjected( + where: ConsulteeProfileWhereInput( + userId: StringFilter(equals: userId), + ), + ); if (profile == null) return false; final consulteeProfileId = profile['id'] as String; if (type == 'CONSULTATION') { - final query = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findFirst) - .where({ - 'id': bookingId, - 'requestedById': consulteeProfileId, - }).build(); - - final result = await executeQueryAsSingleMap(query); + final result = await _prisma.consultation.findFirstProjected( + where: ConsultationWhereInput( + id: StringFilter(equals: bookingId), + requestedById: StringFilter(equals: consulteeProfileId), + ), + ); return result != null; } else if (type == 'TRIAL') { - final query = JsonQueryBuilder() - .model('TrialSession') - .action(QueryAction.findFirst) - .where({ - 'id': bookingId, - 'consulteeProfileId': consulteeProfileId, - }).build(); - - final result = await executeQueryAsSingleMap(query); + final result = await _prisma.trialSession.findFirstProjected( + where: TrialSessionWhereInput( + id: StringFilter(equals: bookingId), + consulteeProfileId: StringFilter(equals: consulteeProfileId), + ), + ); return result != null; } else { - final query = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findFirst) - .where({ - 'id': bookingId, - 'requestedById': consulteeProfileId, - }).build(); - - final result = await executeQueryAsSingleMap(query); + final result = await _prisma.subscription.findFirstProjected( + where: SubscriptionWhereInput( + id: StringFilter(equals: bookingId), + requestedById: StringFilter(equals: consulteeProfileId), + ), + ); return result != null; } } @@ -2998,31 +2726,31 @@ class AppointmentRepository extends BaseRepository { // For WEBINAR and CLASS, check enrollment via SlotOfAppointment M2M if (type == 'WEBINAR' || type == 'CLASS') { // Get the appointment for this webinar/class - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findFirst) - .where({ - 'appointmentType': type, - if (type == 'WEBINAR') 'webinarId': bookingId, - if (type == 'CLASS') 'classId': bookingId, - }).build(); - - final appointment = await executeQueryAsSingleMap(appointmentQuery); + final appointment = await _prisma.appointment.findFirstProjected( + where: AppointmentWhereInput( + appointmentType: AppointmentsTypeFilter( + equals: _appointmentsTypeFromString(type), + ), + webinarId: type == 'WEBINAR' ? StringFilter(equals: bookingId) : null, + classId: type == 'CLASS' ? StringFilter(equals: bookingId) : null, + ), + ); if (appointment == null) return false; // Check if user is enrolled via SlotOfAppointment // Use nested filter: slots.some(user.some(id == userId)) - final enrollmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findFirst) - .where({ - 'id': appointment['id'], - 'slotsOfAppointment': FilterOperators.some({ - 'user': FilterOperators.some({'id': userId}), - }), - }).build(); - - final result = await executeQueryAsSingleMap(enrollmentQuery); + final result = await _prisma.appointment.findFirstProjected( + where: AppointmentWhereInput( + id: StringFilter(equals: appointment['id'] as String), + slotsOfAppointment: SlotOfAppointmentListRelationFilter( + some: SlotOfAppointmentWhereInput( + user: UserListRelationFilter( + some: UserWhereInput(id: StringFilter(equals: userId)), + ), + ), + ), + ), + ); return result != null; } @@ -3035,23 +2763,126 @@ class AppointmentRepository extends BaseRepository { Future<({Map? profile, Map? user})> _fetchConsulteeInfo( String? consulteeProfileId, { - TransactionExecutor? txn, + PrismaClient? client, }) async { if (consulteeProfileId == null) { return (profile: null, user: null); } - final consulteeQuery = JsonQueryBuilder() - .model('ConsulteeProfile') - .action(QueryAction.findUnique) - .where({'id': consulteeProfileId}) - .include({'user': true}) - .build(); final profile = - await executeQueryAsSingleMap(consulteeQuery, txn: txn); + await (client ?? _prisma).consulteeProfile.findFirstProjected( + where: ConsulteeProfileWhereInput( + id: StringFilter(equals: consulteeProfileId), + ), + include: const ConsulteeProfileInclude(user: UserInclude()), + ); final user = profile?['user'] as Map?; return (profile: profile, user: user); } + + /// Respond to a pending booking request (consultant approve/reject). + /// + /// Ported from dev's payment-free booking flow into the typed surface. + /// Uses a compare-and-set on the PENDING status (updateMany with the status + /// in the where) so two concurrent responses can't both win. + Future> respondToBookingRequest({ + required String id, + required String type, + required String userId, + required bool approve, + }) async { + // Resolve the consultant profile for authorization + final profile = await _prisma.consultantProfile.findFirstProjected( + where: ConsultantProfileWhereInput(userId: StringFilter(equals: userId)), + select: [ConsultantProfileScalarField.id], + ); + if (profile == null) { + throw Exception('Booking not found or you do not have permission'); + } + final consultantProfileId = profile['id'] as String; + + if (type != 'CONSULTATION' && type != 'SUBSCRIPTION') { + throw Exception('Invalid booking type'); + } + final isConsultation = type == 'CONSULTATION'; + + final newStatus = approve + ? AppointmentStatus.approvedPendingPayment + : AppointmentStatus.rejected; + final now = DateTime.now().toUtc(); + + int affected; + if (isConsultation) { + final booking = await _prisma.consultation.findFirstProjected( + where: ConsultationWhereInput( + id: StringFilter(equals: id), + status: + const AppointmentStatusFilter(equals: AppointmentStatus.pending), + ), + select: [ConsultationScalarField.consultationPlanId], + ); + if (booking == null) { + throw Exception('Booking request not found or no longer pending'); + } + final plan = await _prisma.consultationPlan.findFirstProjected( + where: ConsultationPlanWhereInput( + id: StringFilter(equals: booking['consultationPlanId'] as String?), + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: [ConsultationPlanScalarField.id], + ); + if (plan == null) { + throw Exception('Booking not found or you do not have permission'); + } + affected = await _prisma.consultation.updateMany( + where: ConsultationWhereInput( + id: StringFilter(equals: id), + status: + const AppointmentStatusFilter(equals: AppointmentStatus.pending), + ), + data: UpdateConsultationInput(status: newStatus), + ); + } else { + final booking = await _prisma.subscription.findFirstProjected( + where: SubscriptionWhereInput( + id: StringFilter(equals: id), + status: + const AppointmentStatusFilter(equals: AppointmentStatus.pending), + ), + select: [SubscriptionScalarField.subscriptionPlanId], + ); + if (booking == null) { + throw Exception('Booking request not found or no longer pending'); + } + final plan = await _prisma.subscriptionPlan.findFirstProjected( + where: SubscriptionPlanWhereInput( + id: StringFilter(equals: booking['subscriptionPlanId'] as String?), + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: [SubscriptionPlanScalarField.id], + ); + if (plan == null) { + throw Exception('Booking not found or you do not have permission'); + } + affected = await _prisma.subscription.updateMany( + where: SubscriptionWhereInput( + id: StringFilter(equals: id), + status: + const AppointmentStatusFilter(equals: AppointmentStatus.pending), + ), + data: UpdateSubscriptionInput(status: newStatus), + ); + } + if (affected == 0) { + throw Exception('Booking request not found or no longer pending'); + } + + return { + 'id': id, + 'status': newStatus.toJson(), + 'respondedAt': now.toIso8601String(), + }; + } } diff --git a/backend/lib/database/repositories/checkout_repository.dart b/backend/lib/database/repositories/checkout_repository.dart index 1a1315d..021ce38 100644 --- a/backend/lib/database/repositories/checkout_repository.dart +++ b/backend/lib/database/repositories/checkout_repository.dart @@ -1,11 +1,13 @@ import 'package:backend/database/repositories/base_repository.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; +import 'package:backend/utils/enum_utils.dart'; +import 'package:backend/generated/index.dart'; import 'package:uuid/uuid.dart'; /// Repository for checkout and payment operations class CheckoutRepository extends BaseRepository { - CheckoutRepository(super._executor); + CheckoutRepository(super._executor, this._prisma); + final PrismaClient _prisma; final _uuid = const Uuid(); /// Create a payment record for checkout @@ -23,38 +25,29 @@ class CheckoutRepository extends BaseRepository { String? discountCodeId, String? description, }) async { - final paymentId = _uuid.v4(); final paymentIntent = 'pi_${_uuid.v4().replaceAll('-', '')}'; - final now = nowIso8601; - // Create payment record - final createQuery = - JsonQueryBuilder().model('Payment').action(QueryAction.create).data({ - 'id': paymentId, - 'amount': amount, - 'originalAmount': originalAmount ?? amount, - 'currency': currency, - 'paymentMethod': 'CARD', - 'paymentIntent': paymentIntent, - 'paymentGateway': paymentGateway, - 'paymentStatus': 'PENDING', - 'isMockPayment': false, - 'userId': userId, - if (appointmentId != null) 'appointmentId': appointmentId, - if (discountCodeId != null) 'discountCodeId': discountCodeId, - if (description != null) 'description': description, - 'expiresAt': DateTime.now() - .add(const Duration(hours: 1)) - .toUtc() - .toIso8601String(), - 'createdAt': now, - 'updatedAt': now, - }).build(); - - await executeMutation(createQuery); + final payment = await _prisma.payment.create( + data: CreatePaymentInput( + amount: BigInt.from(amount), + originalAmount: BigInt.from(originalAmount ?? amount), + currency: enumFromWire(Currency.values, currency, field: 'currency'), + paymentMethod: 'CARD', + paymentIntent: paymentIntent, + paymentGateway: enumFromWire(PaymentGateway.values, paymentGateway, + field: 'paymentGateway'), + paymentStatus: PaymentStatus.pending, + isMockPayment: false, + userId: userId, + appointmentId: appointmentId, + discountCodeId: discountCodeId, + description: description, + expiresAt: DateTime.now().add(const Duration(hours: 1)).toUtc(), + ), + ); return { - 'paymentId': paymentId, + 'paymentId': payment.id, 'paymentIntent': paymentIntent, 'amount': amount, 'currency': currency, @@ -64,12 +57,10 @@ class CheckoutRepository extends BaseRepository { /// Get payment by ID Future?> getPaymentById(String paymentId) async { - final query = JsonQueryBuilder() - .model('Payment') - .action(QueryAction.findUnique) - .where({'id': paymentId}).build(); - - return executeQueryAsSingleMap(query); + final payment = await _prisma.payment.findUnique( + where: PaymentWhereUniqueInput(id: paymentId), + ); + return payment?.toJson(); } /// Update payment status @@ -78,16 +69,14 @@ class CheckoutRepository extends BaseRepository { required String status, String? receiptUrl, }) async { - final updateQuery = JsonQueryBuilder() - .model('Payment') - .action(QueryAction.update) - .where({'id': paymentId}).data({ - 'paymentStatus': status, - if (receiptUrl != null) 'receiptUrl': receiptUrl, - 'updatedAt': nowIso8601, - }).build(); - - await executeMutation(updateQuery); + await _prisma.payment.update( + where: PaymentWhereUniqueInput(id: paymentId), + data: UpdatePaymentInput( + paymentStatus: + enumFromWire(PaymentStatus.values, status, field: 'status'), + receiptUrl: receiptUrl, + ), + ); } /// Update payment intent (e.g., with Razorpay order ID) @@ -95,42 +84,32 @@ class CheckoutRepository extends BaseRepository { required String paymentId, required String paymentIntent, }) async { - final updateQuery = JsonQueryBuilder() - .model('Payment') - .action(QueryAction.update) - .where({'id': paymentId}).data({ - 'paymentIntent': paymentIntent, - 'updatedAt': nowIso8601, - }).build(); - await executeMutation(updateQuery); + await _prisma.payment.update( + where: PaymentWhereUniqueInput(id: paymentId), + data: UpdatePaymentInput(paymentIntent: paymentIntent), + ); } /// Get plan price and details Future?> getConsultationPlan(String planId) async { - final query = JsonQueryBuilder() - .model('ConsultationPlan') - .action(QueryAction.findUnique) - .where({'id': planId}).include({ - 'consultantProfile': { - 'include': {'user': true} - } - }).build(); - - return executeQueryAsSingleMap(query); + final plan = await _prisma.consultationPlan.findUnique( + where: ConsultationPlanWhereUniqueInput(id: planId), + include: ConsultationPlanInclude( + consultantProfile: ConsultantProfileInclude(user: UserInclude()), + ), + ); + return plan?.toJson(); } /// Get subscription plan price and details Future?> getSubscriptionPlan(String planId) async { - final query = JsonQueryBuilder() - .model('SubscriptionPlan') - .action(QueryAction.findUnique) - .where({'id': planId}).include({ - 'consultantProfile': { - 'include': {'user': true} - } - }).build(); - - return executeQueryAsSingleMap(query); + final plan = await _prisma.subscriptionPlan.findUnique( + where: SubscriptionPlanWhereUniqueInput(id: planId), + include: SubscriptionPlanInclude( + consultantProfile: ConsultantProfileInclude(user: UserInclude()), + ), + ); + return plan?.toJson(); } /// Get booking details by ID (consultation or subscription) @@ -139,35 +118,25 @@ class CheckoutRepository extends BaseRepository { String bookingType, ) async { if (bookingType.toUpperCase() == 'CONSULTATION') { - final query = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findUnique) - .where({'id': bookingId}).include({ - 'consultationPlan': { - 'include': { - 'consultantProfile': { - 'include': {'user': true} - } - } - } - }).build(); - - return executeQueryAsSingleMap(query); + final booking = await _prisma.consultation.findUnique( + where: ConsultationWhereUniqueInput(id: bookingId), + include: ConsultationInclude( + consultationPlan: ConsultationPlanInclude( + consultantProfile: ConsultantProfileInclude(user: UserInclude()), + ), + ), + ); + return booking?.toJson(); } else { - final query = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findUnique) - .where({'id': bookingId}).include({ - 'subscriptionPlan': { - 'include': { - 'consultantProfile': { - 'include': {'user': true} - } - } - } - }).build(); - - return executeQueryAsSingleMap(query); + final booking = await _prisma.subscription.findUnique( + where: SubscriptionWhereUniqueInput(id: bookingId), + include: SubscriptionInclude( + subscriptionPlan: SubscriptionPlanInclude( + consultantProfile: ConsultantProfileInclude(user: UserInclude()), + ), + ), + ); + return booking?.toJson(); } } @@ -176,23 +145,22 @@ class CheckoutRepository extends BaseRepository { required String code, double? amount, }) async { - final query = JsonQueryBuilder() - .model('DiscountCode') - .action(QueryAction.findFirst) - .where({ - 'code': code.toUpperCase(), - 'isActive': true, - }).build(); - - final discount = await executeQueryAsSingleMap(query); - - if (discount == null) { + final discountModel = await _prisma.discountCode.findFirst( + where: DiscountCodeWhereInput( + code: StringFilter(equals: code.toUpperCase()), + isActive: BooleanFilter(equals: true), + ), + ); + + if (discountModel == null) { return { 'valid': false, 'reason': 'not_found', }; } + final discount = discountModel.toJson(); + final expiresAt = _parseDateTime(discount['expiresAt']); final now = DateTime.now().toUtc(); @@ -223,8 +191,14 @@ class CheckoutRepository extends BaseRepository { if (amount != null) { if (discountType == 'PERCENTAGE') { discountAmount = (amount * discountValue / 100); - // Apply max discount if set - final maxDiscount = (discount['maxDiscount'] as num?)?.toDouble(); + // Apply max discount if set. maxDiscount is a BigInt column, which + // toJson() serializes as a String — parse rather than cast. + final maxDiscount = switch (discount['maxDiscount']) { + final num n => n.toDouble(), + final BigInt b => b.toDouble(), + final String str => double.tryParse(str), + _ => null, + }; if (maxDiscount != null && discountAmount > maxDiscount) { discountAmount = maxDiscount; } @@ -264,44 +238,39 @@ class CheckoutRepository extends BaseRepository { required String bookingType, required String status, }) async { - final model = bookingType.toUpperCase() == 'CONSULTATION' - ? 'Consultation' - : 'Subscription'; - - final updateQuery = JsonQueryBuilder() - .model(model) - .action(QueryAction.update) - .where({'id': bookingId}).data({ - 'requestStatus': status, - 'updatedAt': nowIso8601, - }).build(); - - await executeMutation(updateQuery); + // The Dart field is `status` (@map'd to the requestStatus column). + final typedStatus = + enumFromWire(AppointmentStatus.values, status, field: 'status'); + if (bookingType.toUpperCase() == 'CONSULTATION') { + await _prisma.consultation.update( + where: ConsultationWhereUniqueInput(id: bookingId), + data: UpdateConsultationInput(status: typedStatus), + ); + } else { + await _prisma.subscription.update( + where: SubscriptionWhereUniqueInput(id: bookingId), + data: UpdateSubscriptionInput(status: typedStatus), + ); + } } /// Confirm slot bookings (mark as non-tentative) after payment Future confirmSlots(String consultationId) async { // Get appointment for this consultation - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findFirst) - .where({'consultationId': consultationId}).build(); - - final appointment = await executeQueryAsSingleMap(appointmentQuery); + final appointment = await _prisma.appointment.findFirst( + where: AppointmentWhereInput( + consultationId: StringFilter(equals: consultationId), + ), + ); if (appointment == null) return; - final appointmentId = appointment['id'] as String; - // Update all slots to confirmed (non-tentative) - final updateQuery = JsonQueryBuilder() - .model('SlotOfAppointment') - .action(QueryAction.updateMany) - .where({'appointmentId': appointmentId}).data({ - 'isTentative': false, - 'updatedAt': nowIso8601, - }).build(); - - await executeMutation(updateQuery); + await _prisma.slotOfAppointment.updateMany( + where: SlotOfAppointmentWhereInput( + appointmentId: StringFilter(equals: appointment.id), + ), + data: UpdateSlotOfAppointmentInput(isTentative: false), + ); } } diff --git a/backend/lib/database/repositories/collaborator_repository.dart b/backend/lib/database/repositories/collaborator_repository.dart index 423841d..f466502 100644 --- a/backend/lib/database/repositories/collaborator_repository.dart +++ b/backend/lib/database/repositories/collaborator_repository.dart @@ -1,15 +1,9 @@ import 'package:backend/database/repositories/base_repository.dart'; +import 'package:backend/utils/enum_utils.dart'; import 'package:backend/generated/index.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; - -/// Repository for collaborator operations. -/// -/// Uses the unified `Collaborator` model (webinar/class twins were merged -/// upstream; `collaboratorType` discriminates, `webinarPlanId`/`classPlanId` -/// are XOR, and `revenueShareBps` replaced `revenueSharePercentage`). -/// -/// NOTE: collaborations are a deferred (feature-flagged) feature on mobile; -/// this repository covers the read/respond surface only. + +/// Repository for collaborator operations +/// (WebinarCollaborator + ClassCollaborator) class CollaboratorRepository extends BaseRepository { /// Create a collaborator repository with the given executor CollaboratorRepository(super._executor, this._prisma); @@ -19,40 +13,54 @@ class CollaboratorRepository extends BaseRepository { Future> getMyCollaborations( String consultantProfileId, ) async { - final results = await _prisma.collaborator.findManyRaw( - where: { - 'consultantProfileId': consultantProfileId, - 'status': FilterOperators.in_(['PENDING', 'ACCEPTED']), - }, - include: { - 'webinarPlan': { - 'include': { - 'consultantProfile': { - 'include': {'user': true}, - }, - }, - }, - 'classPlan': { - 'include': { - 'consultantProfile': { - 'include': {'user': true}, - }, - }, - }, - 'invitedBy': { - 'include': {'user': true}, - }, - }, + // Webinar collaborations with nested includes. + // TODO(mega-sync): the schema consolidated WebinarCollaborator + + // ClassCollaborator into a single Collaborator model (collaboratorType + // discriminator, revenueShareBps, invitedById, typed permission booleans). + // Filtering by collaboratorType keeps this compiling; the flatten shape + // below still needs updating to the new field names for full correctness. + final webinarResults = await _prisma.collaborator.findMany( + where: CollaboratorWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + collaboratorType: + const CollaboratorTypeFilter(equals: CollaboratorType.webinar), + status: const CollaboratorStatusFilter( + in_: [CollaboratorStatus.pending, CollaboratorStatus.accepted], + ), + ), + include: const CollaboratorInclude( + webinarPlan: WebinarPlanInclude( + consultantProfile: ConsultantProfileInclude(user: UserInclude()), + ), + invitedBy: ConsultantProfileInclude(user: UserInclude()), + ), orderBy: {'createdAt': 'desc'}, ); - - final webinarCollaborations = results - .where((c) => c['collaboratorType'] == 'WEBINAR') - .map(_flattenCollaboration) + final webinarCollaborations = webinarResults + .map((r) => _flattenWebinarCollaboration(r.toJson())) .toList(); - final classCollaborations = results - .where((c) => c['collaboratorType'] == 'CLASS') - .map(_flattenCollaboration) + + // Class collaborations with nested includes (see TODO above). + final classResults = await _prisma.collaborator.findMany( + where: CollaboratorWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + collaboratorType: + const CollaboratorTypeFilter(equals: CollaboratorType.classValue), + status: const CollaboratorStatusFilter( + in_: [CollaboratorStatus.pending, CollaboratorStatus.accepted], + ), + ), + include: const CollaboratorInclude( + classPlan: ClassPlanInclude( + consultantProfile: ConsultantProfileInclude(user: UserInclude()), + ), + invitedBy: ConsultantProfileInclude(user: UserInclude()), + ), + orderBy: {'createdAt': 'desc'}, + ); + + final classCollaborations = classResults + .map((r) => _flattenClassCollaboration(r.toJson())) .toList(); final counts = await getCollaborationCounts(consultantProfileId); @@ -71,40 +79,42 @@ class CollaboratorRepository extends BaseRepository { required String response, required String planType, }) async { - final now = DateTime.now().toUtc().toIso8601String(); + // WebinarCollaborator + ClassCollaborator were consolidated into a single + // Collaborator model; the id is unique across it, so planType no longer + // selects a table. + final now = DateTime.now().toUtc(); // First check the record exists and is PENDING for this consultant - final findQuery = JsonQueryBuilder() - .model('Collaborator') - .action(QueryAction.findFirst) - .where({ - 'id': id, - 'consultantProfileId': consultantProfileId, - 'collaboratorType': planType == 'webinar' ? 'WEBINAR' : 'CLASS', - 'status': 'PENDING', - }) - .build(); - - final existing = await executeQueryAsSingleMap(findQuery); + final existing = await _prisma.collaborator.findFirst( + where: CollaboratorWhereInput( + id: StringFilter(equals: id), + consultantProfileId: StringFilter(equals: consultantProfileId), + status: + const CollaboratorStatusFilter(equals: CollaboratorStatus.pending), + ), + ); if (existing == null) return null; // Update the record - final updateQuery = JsonQueryBuilder() - .model('Collaborator') - .action(QueryAction.update) - .where({'id': id}) - .data({ - 'status': response, - 'respondedAt': now, - }) - .build(); - - await executeMutation(updateQuery); + await _prisma.collaborator.update( + where: CollaboratorWhereUniqueInput(id: id), + data: UpdateCollaboratorInput( + // Restrict to the two terminal decisions this endpoint documents. + // enumFromWire alone would also accept PENDING/REMOVED, letting a + // client push a collaboration back to pending or silently remove it. + status: enumFromWire( + const [CollaboratorStatus.accepted, CollaboratorStatus.declined], + response, + field: 'response', + ), + respondedAt: now, + ), + ); return { 'id': id, 'status': response, - 'respondedAt': now, + 'respondedAt': now.toIso8601String(), }; } @@ -112,27 +122,25 @@ class CollaboratorRepository extends BaseRepository { Future> getCollaborationCounts( String consultantProfileId, ) async { - final pendingQuery = JsonQueryBuilder() - .model('Collaborator') - .action(QueryAction.count) - .where({ - 'consultantProfileId': consultantProfileId, - 'status': 'PENDING', - }) - .build(); - - final acceptedQuery = JsonQueryBuilder() - .model('Collaborator') - .action(QueryAction.count) - .where({ - 'consultantProfileId': consultantProfileId, - 'status': 'ACCEPTED', - }) - .build(); - + // Single Collaborator model now covers both webinar + class; count by + // status directly (no per-type split needed since the summary sums them). final results = await Future.wait([ - executeCount(pendingQuery), - executeCount(acceptedQuery), + _prisma.collaborator.count( + where: CollaboratorWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + status: const CollaboratorStatusFilter( + equals: CollaboratorStatus.pending, + ), + ), + ), + _prisma.collaborator.count( + where: CollaboratorWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + status: const CollaboratorStatusFilter( + equals: CollaboratorStatus.accepted, + ), + ), + ), ]); return { @@ -141,38 +149,57 @@ class CollaboratorRepository extends BaseRepository { }; } - /// Flatten a nested Collaborator include result to the flat shape expected - /// by the frontend (planTitle, planPrice, hostName, etc.) - Map _flattenCollaboration(Map c) { - final isWebinar = c['collaboratorType'] == 'WEBINAR'; - final plan = (isWebinar ? c['webinarPlan'] : c['classPlan']) - as Map? ?? - {}; + /// Flatten a nested WebinarCollaborator include result to the flat shape + /// expected by the frontend (planTitle, planPrice, hostName, etc.) + Map _flattenWebinarCollaboration(Map wc) { + final plan = wc['webinarPlan'] as Map? ?? {}; + final hostProfile = + plan['consultantProfile'] as Map? ?? {}; + final hostUser = hostProfile['user'] as Map? ?? {}; + final invitedByProfile = wc['invitedBy'] as Map? ?? {}; + final inviterUser = invitedByProfile['user'] as Map? ?? {}; + + return { + 'id': wc['id'], + 'role': wc['role'], + 'status': wc['status'], + 'revenueSharePercentage': (wc['revenueShareBps'] as int?) == null + ? null + : (wc['revenueShareBps'] as int) / 100, + 'createdAt': wc['createdAt'], + 'planId': plan['id'], + 'planTitle': plan['title'], + 'planPrice': plan['price'], + 'durationInHours': plan['durationInHours'], + 'maxParticipants': plan['maxParticipants'], + 'hostName': hostUser['name'], + 'hostImage': hostUser['image'], + 'inviterName': inviterUser['name'], + }; + } + + /// Flatten a nested ClassCollaborator include result to the flat shape + /// expected by the frontend (planTitle, planPrice, hostName, etc.) + Map _flattenClassCollaboration(Map cc) { + final plan = cc['classPlan'] as Map? ?? {}; final hostProfile = plan['consultantProfile'] as Map? ?? {}; final hostUser = hostProfile['user'] as Map? ?? {}; - final invitedByProfile = c['invitedBy'] as Map? ?? {}; - final inviterUser = - invitedByProfile['user'] as Map? ?? {}; + final invitedByProfile = cc['invitedBy'] as Map? ?? {}; + final inviterUser = invitedByProfile['user'] as Map? ?? {}; return { - 'id': c['id'], - 'role': c['role'], - 'status': c['status'], - // bps → percentage for the existing frontend contract (3000 → 30.0). - // Int column today, but tolerate driver/schema drift defensively. - 'revenueSharePercentage': switch (c['revenueShareBps']) { - final num n => n / 100, - final BigInt b => b.toInt() / 100, - final String s => (int.tryParse(s) ?? 0) / 100, - _ => null, - }, - 'createdAt': c['createdAt'], + 'id': cc['id'], + 'role': cc['role'], + 'status': cc['status'], + 'revenueSharePercentage': (cc['revenueShareBps'] as int?) == null + ? null + : (cc['revenueShareBps'] as int) / 100, + 'createdAt': cc['createdAt'], 'planId': plan['id'], 'planTitle': plan['title'], 'planPrice': plan['price'], - if (isWebinar) 'durationInHours': plan['durationInHours'], - if (!isWebinar) 'sessionDurationInHours': plan['sessionDurationInHours'], + 'sessionDurationInHours': plan['sessionDurationInHours'], 'maxParticipants': plan['maxParticipants'], 'hostName': hostUser['name'], 'hostImage': hostUser['image'], diff --git a/backend/lib/database/repositories/consultant_explore_repository.dart b/backend/lib/database/repositories/consultant_explore_repository.dart index 5cdbeb9..6d0e6f7 100644 --- a/backend/lib/database/repositories/consultant_explore_repository.dart +++ b/backend/lib/database/repositories/consultant_explore_repository.dart @@ -1,4 +1,5 @@ import 'package:backend/database/repositories/base_repository.dart'; +import 'package:backend/generated/index.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; @@ -7,63 +8,67 @@ import 'package:prisma_flutter_connector/runtime_server.dart'; /// Provides methods for browsing, filtering, and searching consultants /// with support for pagination and sorting. /// -/// Uses the Prisma Flutter Connector for type-safe queries where possible, -/// with raw SQL fallback for complex queries requiring advanced PostgreSQL -/// features like subqueries in SELECT clauses. +/// Uses the typed PrismaClient delegates (findManyProjected / +/// findFirstProjected / count / aggregate) for all reads. class ConsultantExploreRepository extends BaseRepository { /// Create a consultant explore repository with the given executor - ConsultantExploreRepository(super._executor); + ConsultantExploreRepository(super._executor, this._prisma); - /// Build ORM WHERE conditions from filter parameters. + final PrismaClient _prisma; + + /// Build typed WHERE conditions from filter parameters. /// - /// This builds a type-safe WHERE map that can be used with JsonQueryBuilder. - /// Supports: scalar filters, relation filters (subDomains, consultationPlans), - /// and OR conditions for search across multiple fields. - Map _buildWhereConditions({ + /// Supports: scalar filters, relation filters (subDomains, + /// consultationPlans), and OR conditions for search across multiple fields. + ConsultantProfileWhereInput _buildWhereConditions({ String? domainId, String? subDomainId, double? minRating, int? maxPrice, String? searchQuery, }) { - final where = { - 'isVerified': true, - }; - - if (domainId != null) { - where['domainId'] = domainId; - } - - if (minRating != null) { - where['rating'] = FilterOperators.gte(minRating); - } - - // Search across headline, description, and user.name using OR - if (searchQuery != null && searchQuery.isNotEmpty) { - where['OR'] = [ - {'headline': FilterOperators.containsInsensitive(searchQuery)}, - {'description': FilterOperators.containsInsensitive(searchQuery)}, - { - 'user': FilterOperators.some({ - 'name': FilterOperators.containsInsensitive(searchQuery), - }), - }, - ]; - } - - // SubDomain filter using many-to-many relation - if (subDomainId != null) { - where['subDomains'] = FilterOperators.some({'id': subDomainId}); - } - - // Price filter using one-to-many relation - if (maxPrice != null) { - where['consultationPlans'] = FilterOperators.some({ - 'price': FilterOperators.lte(maxPrice), - }); - } - - return where; + return ConsultantProfileWhereInput( + isVerified: const BooleanFilter(equals: true), + domainId: domainId != null ? StringFilter(equals: domainId) : null, + rating: minRating != null ? FloatFilter(gte: minRating) : null, + // Search across headline, description, and user.name using OR + OR: (searchQuery != null && searchQuery.isNotEmpty) + ? [ + ConsultantProfileWhereInput( + headline: + StringFilter(contains: searchQuery, mode: 'insensitive'), + ), + ConsultantProfileWhereInput( + description: + StringFilter(contains: searchQuery, mode: 'insensitive'), + ), + ConsultantProfileWhereInput( + user: UserRelationFilter( + is_: UserWhereInput( + name: StringFilter( + contains: searchQuery, + mode: 'insensitive', + ), + ), + ), + ), + ] + : null, + // SubDomain filter using many-to-many relation + subDomains: subDomainId != null + ? SubDomainListRelationFilter( + some: SubDomainWhereInput(id: StringFilter(equals: subDomainId)), + ) + : null, + // Price filter using one-to-many relation + consultationPlans: maxPrice != null + ? ConsultationPlanListRelationFilter( + some: ConsultationPlanWhereInput( + price: BigIntFilter(lte: BigInt.from(maxPrice)), + ), + ) + : null, + ); } /// Find verified consultants with filtering and pagination @@ -99,13 +104,8 @@ class ConsultantExploreRepository extends BaseRepository { searchQuery: searchQuery, ); - // Count total using ORM with relation filters - final countQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.count) - .where(ormWhere) - .build(); - final totalCount = await executeCount(countQuery); + // Count total using the typed delegate with relation filters + final totalCount = await _prisma.consultantProfile.count(where: ormWhere); // Determine sort field and direction final sortField = switch (sortBy) { @@ -116,60 +116,60 @@ class ConsultantExploreRepository extends BaseRepository { final sortDirection = sortDesc ? 'desc' : 'asc'; final nullsPosition = sortDesc ? 'last' : 'first'; - // Build main query using ORM with ComputedField + include() (v0.2.6) - // The alias conflict fix allows computed() and include() to work together. + // Build main query using the typed projected finder with computed + // subqueries + include-with-select. // This single query replaces 3 separate batch fetches. - final mainQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findMany) - .selectFields([ - 'id', - 'userId', - 'headline', - 'description', - 'rating', - 'experience', - 'languages', - 'toolsAndTechnologies', - 'totalMenteesHelped', - 'isVerified', - 'domainId', - 'createdAt', - ]) - .computed({ - 'minPrice': ComputedField.min( - 'price', - from: 'ConsultationPlan', - where: {'consultantProfileId': FieldRef('id')}, - ), - 'priceCurrency': ComputedField.first( - 'priceCurrency', - from: 'ConsultationPlan', - where: {'consultantProfileId': FieldRef('id')}, - orderBy: {'price': 'asc'}, - ), - }) - .include({ - 'user': { - 'select': {'name': true, 'image': true}, - }, - 'domain': { - 'select': {'id': true, 'name': true}, - }, - 'subDomains': { - 'select': {'id': true, 'name': true, 'domainId': true}, - }, - }) - .where(ormWhere) - .orderBy({ - sortField: {'sort': sortDirection, 'nulls': nullsPosition}, - 'createdAt': 'desc', - }) - .take(effectivePageSize) - .skip(offset) - .build(); - - final consultantsResult = await executeQueryAsMaps(mainQuery); + final consultantsResult = await _prisma.consultantProfile.findManyProjected( + where: ormWhere, + select: [ + ConsultantProfileScalarField.id, + ConsultantProfileScalarField.userId, + ConsultantProfileScalarField.headline, + ConsultantProfileScalarField.description, + ConsultantProfileScalarField.rating, + ConsultantProfileScalarField.experience, + ConsultantProfileScalarField.languages, + ConsultantProfileScalarField.toolsAndTechnologies, + ConsultantProfileScalarField.totalMenteesHelped, + ConsultantProfileScalarField.isVerified, + ConsultantProfileScalarField.domainId, + ConsultantProfileScalarField.createdAt, + ], + computed: { + 'minPrice': ComputedField.min( + 'price', + from: 'ConsultationPlan', + where: {'consultantProfileId': const FieldRef('id')}, + ), + 'priceCurrency': ComputedField.first( + 'priceCurrency', + from: 'ConsultationPlan', + where: {'consultantProfileId': const FieldRef('id')}, + orderBy: {'price': 'asc'}, + ), + }, + include: const ConsultantProfileInclude( + user: UserInclude( + select: [UserScalarField.name, UserScalarField.image], + ), + domain: DomainInclude( + select: [DomainScalarField.id, DomainScalarField.name], + ), + subDomains: SubDomainInclude( + select: [ + SubDomainScalarField.id, + SubDomainScalarField.name, + SubDomainScalarField.domainId, + ], + ), + ), + orderBy: { + sortField: {'sort': sortDirection, 'nulls': nullsPosition}, + 'createdAt': 'desc', + }, + take: effectivePageSize, + skip: offset, + ); // Build consultant list - computed fields (minPrice, priceCurrency) // are included in the result via ComputedField subqueries @@ -216,63 +216,63 @@ class ConsultantExploreRepository extends BaseRepository { /// Returns consultant profile with user info, domain, subdomains, /// consultation plans, subscription plans, and review summary. /// - /// Uses Prisma Flutter Connector v0.2.6 ORM queries with include(). - /// + /// Uses the typed findFirstProjected with include-with-select. /// [userOrgIds] unlocks ORG_ONLY plans owned by the viewer's orgs; /// anonymous viewers see only PUBLIC / ORG_AND_PUBLIC plans. Future?> findByIdWithDetails( String id, { List userOrgIds = const [], }) async { - // Get consultant profile with included relations using ORM - final profileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findFirst) - .selectFields([ - 'id', - 'userId', - 'headline', - 'description', - 'rating', - 'experience', - 'languages', - 'toolsAndTechnologies', - 'totalMenteesHelped', - 'isVerified', - 'domainId', - 'mentoringStyle', - 'sessionTypes', - 'websiteUrl', - 'twitterUrl', - 'githubUrl', - 'videoIntroUrl', - 'createdAt', - 'updatedAt', - ]).include({ - 'user': { - 'select': { - 'name': true, - 'image': true, - 'email': true, - 'timezone': true, - }, - }, - 'domain': { - 'select': {'id': true, 'name': true}, - }, - 'subDomains': { - 'select': {'id': true, 'name': true, 'domainId': true}, - }, - 'tags': { - 'select': {'name': true}, - }, - }).where({'id': id}).build(); - - final profileResult = await executeQueryAsMaps(profileQuery); - - if (profileResult.isEmpty) return null; + // Get consultant profile with included relations using the typed delegate + final row = await _prisma.consultantProfile.findFirstProjected( + where: ConsultantProfileWhereInput(id: StringFilter(equals: id)), + select: const [ + ConsultantProfileScalarField.id, + ConsultantProfileScalarField.userId, + ConsultantProfileScalarField.headline, + ConsultantProfileScalarField.description, + ConsultantProfileScalarField.rating, + ConsultantProfileScalarField.experience, + ConsultantProfileScalarField.languages, + ConsultantProfileScalarField.toolsAndTechnologies, + ConsultantProfileScalarField.totalMenteesHelped, + ConsultantProfileScalarField.isVerified, + ConsultantProfileScalarField.domainId, + ConsultantProfileScalarField.mentoringStyle, + ConsultantProfileScalarField.sessionTypes, + ConsultantProfileScalarField.websiteUrl, + ConsultantProfileScalarField.twitterUrl, + ConsultantProfileScalarField.githubUrl, + ConsultantProfileScalarField.videoIntroUrl, + ConsultantProfileScalarField.createdAt, + ConsultantProfileScalarField.updatedAt, + ], + include: const ConsultantProfileInclude( + user: UserInclude( + select: [ + UserScalarField.name, + UserScalarField.image, + UserScalarField.email, + UserScalarField.timezone, + ], + ), + domain: DomainInclude( + select: [DomainScalarField.id, DomainScalarField.name], + ), + subDomains: SubDomainInclude( + select: [ + SubDomainScalarField.id, + SubDomainScalarField.name, + SubDomainScalarField.domainId, + ], + ), + tags: TagInclude( + select: [TagScalarField.name], + ), + ), + ); - final row = profileResult.first; + if (row == null) return null; // Fetch additional data in parallel: // consultation plans, subscription plans, review summary @@ -306,8 +306,8 @@ class ConsultantExploreRepository extends BaseRepository { 'domain': row['domain'], 'subDomains': row['subDomains'] ?? >[], 'tags': (row['tags'] as List?) - ?.map((t) => (t as Map)['name'] as String) - .toList() ?? + ?.map((t) => (t as Map)['name'] as String) + .toList() ?? [], 'consultationPlans': results[0], 'subscriptionPlans': results[1], @@ -317,7 +317,7 @@ class ConsultantExploreRepository extends BaseRepository { /// Get paginated reviews for a consultant /// - /// Uses Prisma Flutter Connector v0.2.6 ORM queries. + /// Uses the typed ConsultantReview delegate. Future> getReviews({ required String consultantId, int page = 0, @@ -326,31 +326,29 @@ class ConsultantExploreRepository extends BaseRepository { final effectivePageSize = pageSize.clamp(1, 50); final offset = page * effectivePageSize; - // Count total reviews using ORM - final countQuery = JsonQueryBuilder() - .model('ConsultantReview') - .action(QueryAction.count) - .where({'consultantProfileId': consultantId}).build(); - final totalCount = await executeCount(countQuery); - - // Get paginated reviews using ORM - final reviewsQuery = JsonQueryBuilder() - .model('ConsultantReview') - .action(QueryAction.findMany) - .selectFields([ - 'id', - 'rating', - 'reviewDescription', - 'consulteeProfileId', - 'createdAt', - ]) - .where({'consultantProfileId': consultantId}) - .orderBy({'createdAt': 'desc'}) - .take(effectivePageSize) - .skip(offset) - .build(); - - final reviewsResult = await executeQueryAsMaps(reviewsQuery); + // Count total reviews using the typed delegate + final totalCount = await _prisma.consultantReview.count( + where: ConsultantReviewWhereInput( + consultantProfileId: StringFilter(equals: consultantId), + ), + ); + + // Get paginated reviews using the typed projected finder + final reviewsResult = await _prisma.consultantReview.findManyProjected( + select: const [ + ConsultantReviewScalarField.id, + ConsultantReviewScalarField.rating, + ConsultantReviewScalarField.reviewDescription, + ConsultantReviewScalarField.consulteeProfileId, + ConsultantReviewScalarField.createdAt, + ], + where: ConsultantReviewWhereInput( + consultantProfileId: StringFilter(equals: consultantId), + ), + orderBy: {'createdAt': 'desc'}, + take: effectivePageSize, + skip: offset, + ); // Get consultee profile IDs to fetch reviewer info final consulteeProfileIds = reviewsResult @@ -411,13 +409,15 @@ class ConsultantExploreRepository extends BaseRepository { if (consulteeProfileIds.isEmpty) return {}; // First fetch consultee profiles to get user IDs - final profilesQuery = JsonQueryBuilder() - .model('ConsulteeProfile') - .action(QueryAction.findMany) - .selectFields(['id', 'userId']).where( - {'id': FilterOperators.in_(consulteeProfileIds)}).build(); - - final profiles = await executeQueryAsMaps(profilesQuery); + final profiles = await _prisma.consulteeProfile.findManyProjected( + select: const [ + ConsulteeProfileScalarField.id, + ConsulteeProfileScalarField.userId, + ], + where: ConsulteeProfileWhereInput( + id: StringFilter(in_: consulteeProfileIds), + ), + ); // Map consulteeProfileId -> userId final profileToUserMap = {}; @@ -433,13 +433,16 @@ class ConsultantExploreRepository extends BaseRepository { if (userIds.isEmpty) return {}; - // Fetch users using ORM - use actual table name 'users' (not model name 'User') - final usersQuery = JsonQueryBuilder() - .model('users') - .action(QueryAction.findMany) - .selectFields(['id', 'name', 'image']).where( - {'id': FilterOperators.in_(userIds)}).build(); - final users = await executeQueryAsMaps(usersQuery); + // Fetch users using the typed delegate (registry resolves the @map'd + // 'users' table name) + final users = await _prisma.user.findManyProjected( + select: const [ + UserScalarField.id, + UserScalarField.name, + UserScalarField.image, + ], + where: UserWhereInput(id: StringFilter(in_: userIds)), + ); // Map userId -> user data final userMap = >{}; @@ -464,34 +467,72 @@ class ConsultantExploreRepository extends BaseRepository { /// Fetch consultation plans for a consultant /// - /// Uses the ORM with selectFields() for type-safe field selection (v0.2.5+) + /// Uses the typed projected finder for type-safe field selection + + /// Typed plan-visibility filter: PUBLIC/ORG_AND_PUBLIC for everyone, plus + /// ORG_ONLY plans owned by one of the viewer's orgs. + static List _consultationVisibilityOr( + List userOrgIds, + ) => + [ + const ConsultationPlanWhereInput( + visibility: OrgPlanVisibilityFilter( + in_: [OrgPlanVisibility.public, OrgPlanVisibility.orgAndPublic], + ), + ), + if (userOrgIds.isNotEmpty) + ConsultationPlanWhereInput( + visibility: const OrgPlanVisibilityFilter( + equals: OrgPlanVisibility.orgOnly, + ), + organizationId: StringFilter(in_: userOrgIds), + ), + ]; + + /// Subscription-plan twin of [_consultationVisibilityOr]. + static List _subscriptionVisibilityOr( + List userOrgIds, + ) => + [ + const SubscriptionPlanWhereInput( + visibility: OrgPlanVisibilityFilter( + in_: [OrgPlanVisibility.public, OrgPlanVisibility.orgAndPublic], + ), + ), + if (userOrgIds.isNotEmpty) + SubscriptionPlanWhereInput( + visibility: const OrgPlanVisibilityFilter( + equals: OrgPlanVisibility.orgOnly, + ), + organizationId: StringFilter(in_: userOrgIds), + ), + ]; + Future>> _fetchConsultationPlans( String consultantId, List userOrgIds, ) async { - // Build ORM query with specific fields - final query = JsonQueryBuilder() - .model('ConsultationPlan') - .action(QueryAction.findMany) - .selectFields([ - 'id', - 'title', - 'description', - 'durationInHours', - 'price', - 'priceCurrency', - 'language', - 'level', - 'prerequisites', - 'materialProvided', - 'learningOutcomes', - 'createdAt', - ]).where({ - 'consultantProfileId': consultantId, - ..._planVisibilityWhere(userOrgIds), - }).orderBy({'durationInHours': 'asc'}).build(); - - final result = await executeQueryAsMaps(query); + final result = await _prisma.consultationPlan.findManyProjected( + select: const [ + ConsultationPlanScalarField.id, + ConsultationPlanScalarField.title, + ConsultationPlanScalarField.description, + ConsultationPlanScalarField.durationInHours, + ConsultationPlanScalarField.price, + ConsultationPlanScalarField.priceCurrency, + ConsultationPlanScalarField.language, + ConsultationPlanScalarField.level, + ConsultationPlanScalarField.prerequisites, + ConsultationPlanScalarField.materialProvided, + ConsultationPlanScalarField.learningOutcomes, + ConsultationPlanScalarField.createdAt, + ], + where: ConsultationPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantId), + OR: _consultationVisibilityOr(userOrgIds), + ), + orderBy: {'durationInHours': 'asc'}, + ); return result.map((row) { return { @@ -511,60 +552,40 @@ class ConsultantExploreRepository extends BaseRepository { }).toList(); } - /// OrgPlanVisibility filter: everyone sees PUBLIC and ORG_AND_PUBLIC; - /// org members additionally see ORG_ONLY plans owned by their orgs. - static Map _planVisibilityWhere(List userOrgIds) { - return { - 'OR': [ - { - 'visibility': { - 'in': ['PUBLIC', 'ORG_AND_PUBLIC'], - }, - }, - if (userOrgIds.isNotEmpty) - { - 'visibility': 'ORG_ONLY', - 'organizationId': {'in': userOrgIds}, - }, - ], - }; - } - /// Fetch subscription plans for a consultant /// - /// Uses the ORM with selectFields() for type-safe field selection (v0.2.5+) + /// Uses the typed projected finder for type-safe field selection Future>> _fetchSubscriptionPlans( String consultantId, List userOrgIds, ) async { - // Build ORM query with specific fields - final query = JsonQueryBuilder() - .model('SubscriptionPlan') - .action(QueryAction.findMany) - .selectFields([ - 'id', - 'title', - 'description', - 'durationInMonths', - 'price', - 'priceCurrency', - 'callsPerWeek', - 'sessionDurationInHours', - 'totalSessions', - 'totalHours', - 'emailSupport', // Note: PostgreSQL enum will return as string - 'language', - 'level', - 'prerequisites', - 'materialProvided', - 'learningOutcomes', - 'createdAt', - ]).where({ - 'consultantProfileId': consultantId, - ..._planVisibilityWhere(userOrgIds), - }).orderBy({'sessionDurationInHours': 'asc'}).build(); - - final result = await executeQueryAsMaps(query); + final result = await _prisma.subscriptionPlan.findManyProjected( + select: const [ + SubscriptionPlanScalarField.id, + SubscriptionPlanScalarField.title, + SubscriptionPlanScalarField.description, + SubscriptionPlanScalarField.durationInMonths, + SubscriptionPlanScalarField.price, + SubscriptionPlanScalarField.priceCurrency, + SubscriptionPlanScalarField.callsPerWeek, + SubscriptionPlanScalarField.sessionDurationInHours, + SubscriptionPlanScalarField.totalSessions, + SubscriptionPlanScalarField.totalHours, + // Note: PostgreSQL enum will return as string + SubscriptionPlanScalarField.emailSupport, + SubscriptionPlanScalarField.language, + SubscriptionPlanScalarField.level, + SubscriptionPlanScalarField.prerequisites, + SubscriptionPlanScalarField.materialProvided, + SubscriptionPlanScalarField.learningOutcomes, + SubscriptionPlanScalarField.createdAt, + ], + where: SubscriptionPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantId), + OR: _subscriptionVisibilityOr(userOrgIds), + ), + orderBy: {'sessionDurationInHours': 'asc'}, + ); return result.map((row) { return { @@ -591,40 +612,39 @@ class ConsultantExploreRepository extends BaseRepository { /// Fetch review summary for a consultant /// - /// Uses the ORM with FILTER clause for conditional aggregations (v0.2.5+) + /// Uses the typed aggregate delegate with FILTER clause for conditional + /// aggregations Future> _fetchReviewSummary(String consultantId) async { - // Use ORM aggregate query with FILTER clause for rating distribution - final query = JsonQueryBuilder() - .model('ConsultantReview') - .action(QueryAction.aggregate) - .aggregation({ - '_count': true, - '_avg': {'rating': true}, - '_countFiltered': [ + // Use typed aggregate query with FILTER clause for rating distribution + final result = await _prisma.consultantReview.aggregate( + where: ConsultantReviewWhereInput( + consultantProfileId: StringFilter(equals: consultantId), + ), + count: true, + avg: {'rating': true}, + countFiltered: [ { 'alias': 'fiveStar', - 'filter': {'rating': 5} + 'filter': {'rating': 5}, }, { 'alias': 'fourStar', - 'filter': {'rating': 4} + 'filter': {'rating': 4}, }, { 'alias': 'threeStar', - 'filter': {'rating': 3} + 'filter': {'rating': 3}, }, { 'alias': 'twoStar', - 'filter': {'rating': 2} + 'filter': {'rating': 2}, }, { 'alias': 'oneStar', - 'filter': {'rating': 1} + 'filter': {'rating': 1}, }, ], - }).where({'consultantProfileId': consultantId}).build(); - - final result = await executeQueryAsMaps(query); + ); if (result.isEmpty) { return { @@ -634,7 +654,7 @@ class ConsultantExploreRepository extends BaseRepository { }; } - final row = result.first; + final row = result; // Helper to safely parse numeric values (handles both num and String) double parseDouble(Object? value) { diff --git a/backend/lib/database/repositories/consultant_profile_repository.dart b/backend/lib/database/repositories/consultant_profile_repository.dart index ea76290..6c684b7 100644 --- a/backend/lib/database/repositories/consultant_profile_repository.dart +++ b/backend/lib/database/repositories/consultant_profile_repository.dart @@ -1,7 +1,7 @@ import 'package:backend/database/repositories/base_repository.dart'; +import 'package:backend/utils/enum_utils.dart'; import 'package:backend/generated/index.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// Repository for consultant profile database operations class ConsultantProfileRepository extends BaseRepository { @@ -10,20 +10,20 @@ class ConsultantProfileRepository extends BaseRepository { final PrismaClient _prisma; - static const _uuid = Uuid(); - /// Find consultant profile by user ID Future?> findByUserId(String userId) async { - return _prisma.consultantProfile.findFirstRaw( - where: {'userId': userId}, + final result = await _prisma.consultantProfile.findFirst( + where: ConsultantProfileWhereInput(userId: StringFilter(equals: userId)), ); + return result?.toJson(); } /// Find consultant profile by ID Future?> findById(String id) async { - return _prisma.consultantProfile.findFirstRaw( - where: {'id': id}, + final result = await _prisma.consultantProfile.findFirst( + where: ConsultantProfileWhereInput(id: StringFilter(equals: id)), ); + return result?.toJson(); } /// Upsert a consultant profile (create or update) @@ -49,53 +49,55 @@ class ConsultantProfileRepository extends BaseRepository { String? videoIntroUrl, TransactionExecutor? txn, }) async { - // Check if profile exists to get its ID - final existing = await findByUserId(userId); - final profileId = existing?['id'] as String? ?? _uuid.v4(); - - // Build update data with optional fields using collection-if - final updateData = { - 'domainId': domainId, - 'updatedAt': nowIso8601, - if (experience != null) 'experience': experience, - if (description != null) 'description': description, - if (headline != null) 'headline': headline, - if (languages != null) 'languages': languages, - if (toolsAndTechnologies != null) - 'toolsAndTechnologies': toolsAndTechnologies, - if (mentoringStyle != null) 'mentoringStyle': mentoringStyle, - if (sessionTypes != null) 'sessionTypes': sessionTypes, - 'scheduleType': scheduleType ?? 'WEEKLY', - if (websiteUrl != null) 'websiteUrl': websiteUrl, - if (twitterUrl != null) 'twitterUrl': twitterUrl, - if (githubUrl != null) 'githubUrl': githubUrl, - if (videoIntroUrl != null) 'videoIntroUrl': videoIntroUrl, - }; - - // Build create data (includes all update fields plus required create - // fields) - final createData = { - 'id': profileId, - 'userId': userId, - 'createdAt': nowIso8601, - 'isVerified': false, - ...updateData, - }; + // Map wire strings to generated enums for the typed inputs. + final scheduleTypeEnum = enumFromWire( + ScheduleType.values, scheduleType ?? 'WEEKLY', + field: 'scheduleType'); + final sessionTypeEnums = sessionTypes + ?.map((s) => enumFromWire(SessionType.values, s, field: 'sessionTypes')) + .toList(); - // Use the connector's native upsert (ON CONFLICT DO UPDATE) - final query = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.upsert) - .where({'id': profileId}).data({ - 'create': createData, - 'update': updateData, - }).build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); - if (result == null) { - throw Exception('Failed to upsert consultant profile'); - } - return result; + // Use the connector's native upsert (ON CONFLICT DO UPDATE) keyed on the + // unique userId column; id/timestamps are autofilled on create and + // updatedAt auto-refreshes on update. + final delegate = txn == null + ? _prisma.consultantProfile + : ConsultantProfileDelegate(txn); + final result = await delegate.upsert( + where: ConsultantProfileWhereUniqueInput(userId: userId), + create: CreateConsultantProfileInput( + userId: userId, + domainId: domainId, + scheduleType: scheduleTypeEnum, + experience: experience, + description: description, + headline: headline, + languages: languages, + toolsAndTechnologies: toolsAndTechnologies, + mentoringStyle: mentoringStyle, + sessionTypes: sessionTypeEnums, + websiteUrl: websiteUrl, + twitterUrl: twitterUrl, + githubUrl: githubUrl, + videoIntroUrl: videoIntroUrl, + ), + update: UpdateConsultantProfileInput( + domainId: domainId, + scheduleType: scheduleTypeEnum, + experience: experience, + description: description, + headline: headline, + languages: languages, + toolsAndTechnologies: toolsAndTechnologies, + mentoringStyle: mentoringStyle, + sessionTypes: sessionTypeEnums, + websiteUrl: websiteUrl, + twitterUrl: twitterUrl, + githubUrl: githubUrl, + videoIntroUrl: videoIntroUrl, + ), + ); + return result.toJson(); } /// Update consultant-subdomain relations @@ -110,32 +112,21 @@ class ConsultantProfileRepository extends BaseRepository { required List subDomainIds, TransactionExecutor? txn, }) async { - // First, delete existing relations - final deleteQuery = JsonQueryBuilder() - .model('_ConsultantProfileToSubDomain') - .action(QueryAction.deleteMany) - .where({'A': profileId}).build(); - - await executeMutation(deleteQuery, txn: txn); - - // Batch insert new relations using createMany - if (subDomainIds.isNotEmpty) { - final insertQuery = JsonQueryBuilder() - .model('_ConsultantProfileToSubDomain') - .action(QueryAction.createMany) - .data({ - 'data': subDomainIds - .map( - (subDomainId) => { - 'A': profileId, - 'B': subDomainId, - }, - ) - .toList(), - }).build(); - - await executeMutation(insertQuery, txn: txn); - } + // 0.9.0 nested `set`: replace-semantics on the implicit M2M join table + // (junction clear + connects), through the typed surface. Honors an + // ambient transaction when the caller passes one. + final delegate = ConsultantProfileDelegate(txn ?? executor); + await delegate.update( + where: ConsultantProfileWhereUniqueInput(id: profileId), + data: UpdateConsultantProfileInput( + subDomains: ConsultantProfileSubDomainsWriteInput( + set: [ + for (final subDomainId in subDomainIds) + SubDomainWhereUniqueInput(id: subDomainId), + ], + ), + ), + ); } /// Delete a consultant profile by user ID diff --git a/backend/lib/database/repositories/consultant_verification_repository.dart b/backend/lib/database/repositories/consultant_verification_repository.dart index fcf0b56..f7497ae 100644 --- a/backend/lib/database/repositories/consultant_verification_repository.dart +++ b/backend/lib/database/repositories/consultant_verification_repository.dart @@ -1,7 +1,5 @@ import 'package:backend/database/database_client.dart'; import 'package:backend/database/repositories/base_repository.dart'; -import 'package:backend/utils/json_utils.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// Thrown when a verification submission conflicts with an existing pending one. class VerificationConflictException implements Exception { @@ -43,42 +41,28 @@ class ConsultantVerificationRepository extends BaseRepository { required String consultantProfileId, String? notes, }) async { - return executeInTransaction((txn) async { + return _prisma.$transaction((tx) async { // Check for existing pending verification within transaction - final checkQuery = JsonQueryBuilder() - .model('ConsultantProfileVerification') - .action(QueryAction.findFirst) - .where({ - 'consultantProfileId': consultantProfileId, - 'status': 'PENDING', - }).build(); - - final existing = await txn.executeQueryAsSingleMap(checkQuery); + final existing = await tx.consultantProfileVerification.findFirst( + where: ConsultantProfileVerificationWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + status: const ProfileVerificationStatusFilter( + equals: ProfileVerificationStatus.pending, + ), + ), + ); if (existing != null) { throw const VerificationConflictException(); } - // Create new verification within same transaction - final now = nowIso8601; - final createQuery = JsonQueryBuilder() - .model('ConsultantProfileVerification') - .action(QueryAction.create) - .data({ - 'consultantProfileId': consultantProfileId, - 'status': 'PENDING', - 'notes': notes, - 'submittedAt': now, - 'createdAt': now, - 'updatedAt': now, - }).build(); - - final result = await txn.executeQueryAsSingleMap(createQuery); - if (result == null) { - throw Exception('Failed to create verification'); - } - final serialized = serializeForJson(result); - serialized.putIfAbsent('documents', () => []); - return ConsultantProfileVerification.fromJson(serialized); + // Create new verification within same transaction (id/submittedAt/ + // timestamps autofilled; status defaults to PENDING). + return tx.consultantProfileVerification.create( + data: CreateConsultantProfileVerificationInput( + consultantProfileId: consultantProfileId, + notes: notes, + ), + ); }); } @@ -86,14 +70,11 @@ class ConsultantVerificationRepository extends BaseRepository { Future findLatest( String consultantProfileId, ) async { - final result = - await _prisma.consultantProfileVerification.findFirstRaw( - where: {'consultantProfileId': consultantProfileId}, + return _prisma.consultantProfileVerification.findFirst( + where: ConsultantProfileVerificationWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), ); - if (result == null) return null; - final serialized = serializeForJson(result); - serialized.putIfAbsent('documents', () => []); - return ConsultantProfileVerification.fromJson(serialized); } /// Get a verification by ID. @@ -107,9 +88,12 @@ class ConsultantVerificationRepository extends BaseRepository { Future>> findAll( String consultantProfileId, ) async { - return _prisma.consultantProfileVerification.findManyRaw( - where: {'consultantProfileId': consultantProfileId}, + final results = await _prisma.consultantProfileVerification.findMany( + where: ConsultantProfileVerificationWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), ); + return results.map((r) => r.toJson()).toList(); } /// Add a document to an existing verification. @@ -141,9 +125,12 @@ class ConsultantVerificationRepository extends BaseRepository { Future>> getDocuments( String verificationId, ) async { - return _prisma.profileVerificationDocument.findManyRaw( - where: {'verificationId': verificationId}, + final results = await _prisma.profileVerificationDocument.findMany( + where: ProfileVerificationDocumentWhereInput( + verificationId: StringFilter(equals: verificationId), + ), ); + return results.map((r) => r.toJson()).toList(); } /// Resubmit a verification (creates a new one, supersedes the old). diff --git a/backend/lib/database/repositories/consultee_profile_repository.dart b/backend/lib/database/repositories/consultee_profile_repository.dart index bbc9dd2..5bc9702 100644 --- a/backend/lib/database/repositories/consultee_profile_repository.dart +++ b/backend/lib/database/repositories/consultee_profile_repository.dart @@ -1,7 +1,7 @@ import 'package:backend/database/database_client.dart'; +import 'package:backend/utils/enum_utils.dart'; import 'package:backend/database/repositories/base_repository.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// Repository for consultee profile database operations class ConsulteeProfileRepository extends BaseRepository { @@ -9,20 +9,21 @@ class ConsulteeProfileRepository extends BaseRepository { ConsulteeProfileRepository(super._executor, this._prisma); final PrismaClient _prisma; - static const _uuid = Uuid(); /// Find consultee profile by user ID Future?> findByUserId(String userId) async { - return _prisma.consulteeProfile.findFirstRaw( - where: {'userId': userId}, + final result = await _prisma.consulteeProfile.findFirst( + where: ConsulteeProfileWhereInput(userId: StringFilter(equals: userId)), ); + return result?.toJson(); } /// Find consultee profile by ID Future?> findById(String id) async { - return _prisma.consulteeProfile.findFirstRaw( - where: {'id': id}, + final result = await _prisma.consulteeProfile.findFirst( + where: ConsulteeProfileWhereInput(id: StringFilter(equals: id)), ); + return result?.toJson(); } /// Create a new consultee profile @@ -34,21 +35,15 @@ class ConsulteeProfileRepository extends BaseRepository { required String userId, TransactionExecutor? txn, }) async { - final query = JsonQueryBuilder() - .model('ConsulteeProfile') - .action(QueryAction.create) - .data({ - 'id': id, - 'userId': userId, - 'createdAt': nowIso8601, - 'updatedAt': nowIso8601, - }).build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); - if (result == null) { - throw Exception('Failed to create consultee profile in database'); - } - return result; + // id/createdAt/updatedAt are autofilled by the schema defaults; callers + // should use the returned row's id (CreateConsulteeProfileInput has no + // id param). + final delegate = + txn == null ? _prisma.consulteeProfile : ConsulteeProfileDelegate(txn); + final result = await delegate.create( + data: CreateConsulteeProfileInput(userId: userId), + ); + return result.toJson(); } /// Upsert a consultee profile (create or update) @@ -77,49 +72,41 @@ class ConsulteeProfileRepository extends BaseRepository { String? linkedinUrl, TransactionExecutor? txn, }) async { - // First check if profile exists to get its ID - final existing = await findByUserId(userId); - final profileId = existing?['id'] as String? ?? _uuid.v4(); - - // Build optional fields — ONLY columns that exist in the DB - final optionalData = { - if (aboutMe != null) 'aboutMe': aboutMe, - if (careerStage != null) 'careerStage': careerStage, - if (skillsToDevelop != null) 'skillsToDevelop': skillsToDevelop, - if (budgetPreference != null) 'budgetPreference': budgetPreference, - if (preferredLanguage != null) 'preferredLanguage': preferredLanguage, - if (goals != null) 'goals': goals, - }; - - // Build create data (all fields including required ones) - final createData = { - 'id': profileId, - 'userId': userId, - 'createdAt': nowIso8601, - 'updatedAt': nowIso8601, - ...optionalData, - }; - - // Build update data (only fields that should change on conflict) - final updateData = { - 'updatedAt': nowIso8601, - ...optionalData, - }; + // Map wire strings to generated enums for the typed inputs. + final careerStageEnum = careerStage != null + ? enumFromWire(CareerStage.values, careerStage, field: 'careerStage') + : null; + final budgetPreferenceEnum = budgetPreference != null + ? enumFromWire(BudgetPreference.values, budgetPreference, + field: 'budgetPreference') + : null; - // Use the connector's native upsert (ON CONFLICT DO UPDATE) - final query = JsonQueryBuilder() - .model('ConsulteeProfile') - .action(QueryAction.upsert) - .where({'id': profileId}).data({ - 'create': createData, - 'update': updateData, - }).build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); - if (result == null) { - throw Exception('Failed to upsert consultee profile'); - } - return result; + // Use the connector's native upsert (ON CONFLICT DO UPDATE) keyed on the + // unique userId column; id/timestamps are autofilled on create and + // updatedAt auto-refreshes on update. + final delegate = + txn == null ? _prisma.consulteeProfile : ConsulteeProfileDelegate(txn); + final result = await delegate.upsert( + where: ConsulteeProfileWhereUniqueInput(userId: userId), + create: CreateConsulteeProfileInput( + userId: userId, + aboutMe: aboutMe, + careerStage: careerStageEnum, + skillsToDevelop: skillsToDevelop, + budgetPreference: budgetPreferenceEnum, + preferredLanguage: preferredLanguage, + goals: goals, + ), + update: UpdateConsulteeProfileInput( + aboutMe: aboutMe, + careerStage: careerStageEnum, + skillsToDevelop: skillsToDevelop, + budgetPreference: budgetPreferenceEnum, + preferredLanguage: preferredLanguage, + goals: goals, + ), + ); + return result.toJson(); } /// Delete a consultee profile by user ID diff --git a/backend/lib/database/repositories/dashboard_repository.dart b/backend/lib/database/repositories/dashboard_repository.dart index ff1da0f..aa5a03b 100644 --- a/backend/lib/database/repositories/dashboard_repository.dart +++ b/backend/lib/database/repositories/dashboard_repository.dart @@ -1,11 +1,13 @@ import 'package:backend/database/repositories/base_repository.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; +import 'package:backend/generated/index.dart'; /// Repository for dashboard data aggregation queries /// /// Provides aggregated statistics for both consultee and consultant dashboards. class DashboardRepository extends BaseRepository { - DashboardRepository(super._executor); + DashboardRepository(super._executor, this._prisma); + + final PrismaClient _prisma; /// Get aggregated stats for a consultee user /// @@ -15,12 +17,9 @@ class DashboardRepository extends BaseRepository { required String userId, }) async { // Get consultee profile - final profileQuery = JsonQueryBuilder() - .model('ConsulteeProfile') - .action(QueryAction.findFirst) - .where({'userId': userId}).build(); - - final profile = await executeQueryAsSingleMap(profileQuery); + final profile = await _prisma.consulteeProfile.findFirstProjected( + where: ConsulteeProfileWhereInput(userId: StringFilter(equals: userId)), + ); final consulteeProfileId = profile?['id'] as String?; if (consulteeProfileId == null) { @@ -35,31 +34,29 @@ class DashboardRepository extends BaseRepository { }; } - // GroupBy: aggregate consultation counts per status in the DB - final consultationGroupByQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.groupBy) - .groupByFields(['requestStatus']) - .where({'requestedById': consulteeProfileId}) - .aggregation({'_count': true}) - .build(); - final consultationGrouped = - await executeQueryAsMaps(consultationGroupByQuery); + // GroupBy: aggregate consultation counts per status in the DB. + // Dart field is `status` (@map'd to the requestStatus column); the typed + // groupBy aliases the group key back to the Dart field name. + final consultationGrouped = await _prisma.consultation.groupBy( + by: ['status'], + where: ConsultationWhereInput( + requestedById: StringFilter(equals: consulteeProfileId), + ), + count: true, + ); final consultationCounts = - _parseGroupByCounts(consultationGrouped, 'requestStatus'); + _parseGroupByCounts(consultationGrouped, 'status'); // GroupBy: aggregate subscription counts per status in the DB - final subscriptionGroupByQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.groupBy) - .groupByFields(['requestStatus']) - .where({'requestedById': consulteeProfileId}) - .aggregation({'_count': true}) - .build(); - final subscriptionGrouped = - await executeQueryAsMaps(subscriptionGroupByQuery); + final subscriptionGrouped = await _prisma.subscription.groupBy( + by: ['status'], + where: SubscriptionWhereInput( + requestedById: StringFilter(equals: consulteeProfileId), + ), + count: true, + ); final subscriptionCounts = - _parseGroupByCounts(subscriptionGrouped, 'requestStatus'); + _parseGroupByCounts(subscriptionGrouped, 'status'); // Calculate total spent from completed consultations final totalSpent = await _calculateTotalSpent( @@ -109,19 +106,20 @@ class DashboardRepository extends BaseRepository { final planData = await _prefetchConsultantPlanData(consultantProfileId); // Get rating from profile - final ratingQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findFirst) - .where({'id': consultantProfileId}).select({'rating': true}).build(); - final profileData = await executeQueryAsSingleMap(ratingQuery); + final profileData = await _prisma.consultantProfile.findFirstProjected( + where: ConsultantProfileWhereInput( + id: StringFilter(equals: consultantProfileId), + ), + select: const [ConsultantProfileScalarField.rating], + ); final rating = (profileData?['rating'] as num?)?.toDouble() ?? 0.0; // Count reviews - final reviewCountQuery = JsonQueryBuilder() - .model('ConsultantReview') - .action(QueryAction.count) - .where({'consultantProfileId': consultantProfileId}).build(); - final totalReviews = await executeCount(reviewCountQuery); + final totalReviews = await _prisma.consultantReview.count( + where: ConsultantReviewWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + ); // Count unique clients using pre-fetched plan IDs final uniqueClients = await _countUniqueClients( @@ -163,36 +161,31 @@ class DashboardRepository extends BaseRepository { if (consultantProfileId == null) return []; // Get consultation plans for this consultant - final plansQuery = JsonQueryBuilder() - .model('ConsultationPlan') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}).select( - {'id': true}).build(); - - final plans = await executeQueryAsMaps(plansQuery); + final plans = await _prisma.consultationPlan.findManyProjected( + where: ConsultationPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [ConsultationPlanScalarField.id], + ); final planIds = plans.map((p) => p['id'] as String).toList(); if (planIds.isEmpty) return []; // Get pending consultations - final pendingQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findMany) - .where({ - 'consultationPlanId': {'in': planIds}, - 'requestStatus': 'PENDING', - }) - .include({ - 'requestedBy': { - 'include': {'user': true}, - }, - 'consultationPlan': true, - }) - .orderBy({'requestedAt': 'desc'}) - .take(20) - .build(); - - return executeQueryAsMaps(pendingQuery); + return _prisma.consultation.findManyProjected( + where: ConsultationWhereInput( + consultationPlanId: StringFilter(in_: planIds), + status: const AppointmentStatusFilter( + equals: AppointmentStatus.pending, + ), + ), + include: const ConsultationInclude( + requestedBy: ConsulteeProfileInclude(user: UserInclude()), + consultationPlan: ConsultationPlanInclude(), + ), + orderBy: {'requestedAt': 'desc'}, + take: 20, + ); } /// Get recent reviews for a consultant @@ -202,33 +195,31 @@ class DashboardRepository extends BaseRepository { final consultantProfileId = await _getConsultantProfileId(userId); if (consultantProfileId == null) return []; - final reviewsQuery = JsonQueryBuilder() - .model('ConsultantReview') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}) - .select({ - 'id': true, - 'rating': true, - 'reviewDescription': true, - 'createdAt': true, - 'consulteeProfile': { - 'select': { - 'id': true, - 'user': { - 'select': { - 'id': true, - 'name': true, - 'image': true, - }, - }, - }, - }, - }) - .orderBy({'createdAt': 'desc'}) - .take(10) - .build(); - - return executeQueryAsMaps(reviewsQuery); + return _prisma.consultantReview.findManyProjected( + where: ConsultantReviewWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [ + ConsultantReviewScalarField.id, + ConsultantReviewScalarField.rating, + ConsultantReviewScalarField.reviewDescription, + ConsultantReviewScalarField.createdAt, + ], + include: const ConsultantReviewInclude( + consulteeProfile: ConsulteeProfileInclude( + select: [ConsulteeProfileScalarField.id], + user: UserInclude( + select: [ + UserScalarField.id, + UserScalarField.name, + UserScalarField.image, + ], + ), + ), + ), + orderBy: {'createdAt': 'desc'}, + take: 10, + ); } /// Get earnings summary for a consultant @@ -263,11 +254,9 @@ class DashboardRepository extends BaseRepository { /// Resolves the consultant profile ID for a given user ID. /// Returns null if the user has no consultant profile. Future _getConsultantProfileId(String userId) async { - final profileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findFirst) - .where({'userId': userId}).build(); - final profile = await executeQueryAsSingleMap(profileQuery); + final profile = await _prisma.consultantProfile.findFirstProjected( + where: ConsultantProfileWhereInput(userId: StringFilter(equals: userId)), + ); return profile?['id'] as String?; } @@ -279,12 +268,15 @@ class DashboardRepository extends BaseRepository { String consultantProfileId, ) async { // ConsultationPlan — also fetch prices for earnings calculation - final consultationPlansQuery = JsonQueryBuilder() - .model('ConsultationPlan') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}).select( - {'id': true, 'price': true}).build(); - final consultationPlans = await executeQueryAsMaps(consultationPlansQuery); + final consultationPlans = await _prisma.consultationPlan.findManyProjected( + where: ConsultationPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [ + ConsultationPlanScalarField.id, + ConsultationPlanScalarField.price, + ], + ); final subscriptionPlanIds = await _getPlanIds('SubscriptionPlan', consultantProfileId); @@ -310,12 +302,33 @@ class DashboardRepository extends BaseRepository { String planModel, String consultantProfileId, ) async { - final query = JsonQueryBuilder() - .model(planModel) - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}).select( - {'id': true}).build(); - final plans = await executeQueryAsMaps(query); + final consultantProfileIdFilter = StringFilter(equals: consultantProfileId); + final List> plans; + switch (planModel) { + case 'SubscriptionPlan': + plans = await _prisma.subscriptionPlan.findManyProjected( + where: SubscriptionPlanWhereInput( + consultantProfileId: consultantProfileIdFilter, + ), + select: const [SubscriptionPlanScalarField.id], + ); + case 'WebinarPlan': + plans = await _prisma.webinarPlan.findManyProjected( + where: WebinarPlanWhereInput( + consultantProfileId: consultantProfileIdFilter, + ), + select: const [WebinarPlanScalarField.id], + ); + case 'ClassPlan': + plans = await _prisma.classPlan.findManyProjected( + where: ClassPlanWhereInput( + consultantProfileId: consultantProfileIdFilter, + ), + select: const [ClassPlanScalarField.id], + ); + default: + throw ArgumentError('Unsupported plan model: $planModel'); + } return plans.map((p) => p['id'] as String).toList(); } @@ -347,29 +360,31 @@ class DashboardRepository extends BaseRepository { }) async { final counts = {}; - // 1. Consultations (requestStatus field) + // 1. Consultations (requestStatus column, Dart field `status`) if (planData.consultationPlanIds.isNotEmpty) { - final query = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findMany) - .where({ - 'consultationPlanId': {'in': planData.consultationPlanIds}, - 'requestStatus': {'in': statuses}, - }).select({'requestStatus': true}).build(); - final rows = await executeQueryAsMaps(query); + final rows = await _prisma.consultation.findManyProjected( + where: ConsultationWhereInput( + consultationPlanId: StringFilter(in_: planData.consultationPlanIds), + status: AppointmentStatusFilter( + in_: statuses.map(_toAppointmentStatus).toList(), + ), + ), + select: const [ConsultationScalarField.status], + ); _mergeStatusCounts(counts, rows, 'requestStatus'); } - // 2. Subscriptions (requestStatus field) + // 2. Subscriptions (requestStatus column, Dart field `status`) if (planData.subscriptionPlanIds.isNotEmpty) { - final query = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findMany) - .where({ - 'subscriptionPlanId': {'in': planData.subscriptionPlanIds}, - 'requestStatus': {'in': statuses}, - }).select({'requestStatus': true}).build(); - final rows = await executeQueryAsMaps(query); + final rows = await _prisma.subscription.findManyProjected( + where: SubscriptionWhereInput( + subscriptionPlanId: StringFilter(in_: planData.subscriptionPlanIds), + status: AppointmentStatusFilter( + in_: statuses.map(_toAppointmentStatus).toList(), + ), + ), + select: const [SubscriptionScalarField.status], + ); _mergeStatusCounts(counts, rows, 'requestStatus'); } @@ -379,14 +394,15 @@ class DashboardRepository extends BaseRepository { .whereType() .toList(); if (trialStatuses.isNotEmpty) { - final query = JsonQueryBuilder() - .model('TrialSession') - .action(QueryAction.findMany) - .where({ - 'consultantProfileId': consultantProfileId, - 'status': {'in': trialStatuses}, - }).select({'status': true}).build(); - final rows = await executeQueryAsMaps(query); + final rows = await _prisma.trialSession.findManyProjected( + where: TrialSessionWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + status: TrialSessionStatusFilter( + in_: trialStatuses.map(_toTrialSessionStatus).toList(), + ), + ), + select: const [TrialSessionScalarField.status], + ); // Map trial statuses back to request statuses for aggregation for (final row in rows) { final trialStatus = row['status'] as String?; @@ -404,14 +420,15 @@ class DashboardRepository extends BaseRepository { .whereType() .toList(); if (webinarStatuses.isNotEmpty) { - final query = JsonQueryBuilder() - .model('Webinar') - .action(QueryAction.findMany) - .where({ - 'webinarPlanId': {'in': planData.webinarPlanIds}, - 'status': {'in': webinarStatuses}, - }).select({'status': true}).build(); - final rows = await executeQueryAsMaps(query); + final rows = await _prisma.webinar.findManyProjected( + where: WebinarWhereInput( + webinarPlanId: StringFilter(in_: planData.webinarPlanIds), + status: WebinarStatusFilter( + in_: webinarStatuses.map(_toWebinarStatus).toList(), + ), + ), + select: const [WebinarScalarField.status], + ); for (final row in rows) { final webinarStatus = row['status'] as String?; final requestStatus = _mapWebinarStatusToRequestStatus(webinarStatus); @@ -429,14 +446,15 @@ class DashboardRepository extends BaseRepository { .whereType() .toList(); if (classStatuses.isNotEmpty) { - final query = JsonQueryBuilder() - .model('Class') - .action(QueryAction.findMany) - .where({ - 'classPlanId': {'in': planData.classPlanIds}, - 'status': {'in': classStatuses}, - }).select({'status': true}).build(); - final rows = await executeQueryAsMaps(query); + final rows = await _prisma.classModel.findManyProjected( + where: ClassModelWhereInput( + classPlanId: StringFilter(in_: planData.classPlanIds), + status: ClassStatusFilter( + in_: classStatuses.map(_toClassStatus).toList(), + ), + ), + select: const [ClassModelScalarField.status], + ); for (final row in rows) { final classStatus = row['status'] as String?; final requestStatus = _mapClassStatusToRequestStatus(classStatus); @@ -466,17 +484,17 @@ class DashboardRepository extends BaseRepository { required String consulteeProfileId, }) async { // Get completed consultations with plan prices - final query = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findMany) - .where({ - 'requestedById': consulteeProfileId, - 'requestStatus': { - 'in': ['COMPLETED', 'SCHEDULED'], - }, - }).include({'consultationPlan': true}).build(); - - final consultations = await executeQueryAsMaps(query); + final consultations = await _prisma.consultation.findManyProjected( + where: ConsultationWhereInput( + requestedById: StringFilter(equals: consulteeProfileId), + status: const AppointmentStatusFilter( + in_: [AppointmentStatus.completed, AppointmentStatus.scheduled], + ), + ), + include: const ConsultationInclude( + consultationPlan: ConsultationPlanInclude(), + ), + ); var total = 0.0; for (final c in consultations) { @@ -494,26 +512,23 @@ class DashboardRepository extends BaseRepository { required _ConsultantPlanData planData, }) async { final clientIds = {}; - final activeStatuses = [ - 'COMPLETED', - 'SCHEDULED', - 'APPROVED', - 'APPROVED_PENDING_PAYMENT', + const activeStatuses = [ + AppointmentStatus.completed, + AppointmentStatus.scheduled, + AppointmentStatus.approved, + AppointmentStatus.approvedPendingPayment, ]; // 1. Consultation clients if (planData.consultationPlanIds.isNotEmpty) { - final consultationsQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findMany) - .where({ - 'consultationPlanId': {'in': planData.consultationPlanIds}, - 'requestStatus': {'in': activeStatuses}, - }) - .distinct() - .select({'requestedById': true}) - .build(); - final consultations = await executeQueryAsMaps(consultationsQuery); + final consultations = await _prisma.consultation.findManyProjected( + where: ConsultationWhereInput( + consultationPlanId: StringFilter(in_: planData.consultationPlanIds), + status: const AppointmentStatusFilter(in_: activeStatuses), + ), + distinct: true, + select: const [ConsultationScalarField.requestedById], + ); for (final c in consultations) { final id = c['requestedById'] as String?; if (id != null) clientIds.add(id); @@ -522,17 +537,14 @@ class DashboardRepository extends BaseRepository { // 2. Subscription clients if (planData.subscriptionPlanIds.isNotEmpty) { - final subscriptionsQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findMany) - .where({ - 'subscriptionPlanId': {'in': planData.subscriptionPlanIds}, - 'requestStatus': {'in': activeStatuses}, - }) - .distinct() - .select({'requestedById': true}) - .build(); - final subscriptions = await executeQueryAsMaps(subscriptionsQuery); + final subscriptions = await _prisma.subscription.findManyProjected( + where: SubscriptionWhereInput( + subscriptionPlanId: StringFilter(in_: planData.subscriptionPlanIds), + status: const AppointmentStatusFilter(in_: activeStatuses), + ), + distinct: true, + select: const [SubscriptionScalarField.requestedById], + ); for (final s in subscriptions) { final id = s['requestedById'] as String?; if (id != null) clientIds.add(id); @@ -540,19 +552,21 @@ class DashboardRepository extends BaseRepository { } // 3. Trial session clients - final trialsQuery = JsonQueryBuilder() - .model('TrialSession') - .action(QueryAction.findMany) - .where({ - 'consultantProfileId': consultantProfileId, - 'status': { - 'in': ['PENDING', 'SCHEDULED', 'COMPLETED', 'CONVERTED'], - }, - }) - .distinct() - .select({'consulteeProfileId': true}) - .build(); - final trials = await executeQueryAsMaps(trialsQuery); + final trials = await _prisma.trialSession.findManyProjected( + where: TrialSessionWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + status: const TrialSessionStatusFilter( + in_: [ + TrialSessionStatus.pending, + TrialSessionStatus.scheduled, + TrialSessionStatus.completed, + TrialSessionStatus.converted, + ], + ), + ), + distinct: true, + select: const [TrialSessionScalarField.consulteeProfileId], + ); for (final t in trials) { final id = t['consulteeProfileId'] as String?; if (id != null) clientIds.add(id); @@ -560,28 +574,27 @@ class DashboardRepository extends BaseRepository { // 4. Webinar participants (via slots → users) if (planData.webinarPlanIds.isNotEmpty) { - final webinarsQuery = JsonQueryBuilder() - .model('Webinar') - .action(QueryAction.findMany) - .where({ - 'webinarPlanId': {'in': planData.webinarPlanIds}, - 'status': { - 'in': ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED'], - }, - }).include({ - 'appointment': { - 'include': { - 'slots': { - 'include': {'user': true}, - }, - }, - }, - }).build(); - final webinars = await executeQueryAsMaps(webinarsQuery); + final webinars = await _prisma.webinar.findManyProjected( + where: WebinarWhereInput( + webinarPlanId: StringFilter(in_: planData.webinarPlanIds), + status: const WebinarStatusFilter( + in_: [ + WebinarStatus.scheduled, + WebinarStatus.inProgress, + WebinarStatus.completed, + ], + ), + ), + include: const WebinarInclude( + appointment: AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(user: UserInclude()), + ), + ), + ); for (final w in webinars) { final appointment = w['appointment'] as Map?; if (appointment == null) continue; - final slots = appointment['slots'] as List? ?? []; + final slots = appointment['slotsOfAppointment'] as List? ?? []; for (final slot in slots) { final slotMap = slot as Map; final users = slotMap['user'] as List? ?? []; @@ -595,27 +608,28 @@ class DashboardRepository extends BaseRepository { // 5. Class participants (via slots → users) if (planData.classPlanIds.isNotEmpty) { - final classesQuery = - JsonQueryBuilder().model('Class').action(QueryAction.findMany).where({ - 'classPlanId': {'in': planData.classPlanIds}, - 'status': { - 'in': ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED'], - }, - }).include({ - 'appointments': { - 'include': { - 'slots': { - 'include': {'user': true}, - }, - }, - }, - }).build(); - final classes = await executeQueryAsMaps(classesQuery); + final classes = await _prisma.classModel.findManyProjected( + where: ClassModelWhereInput( + classPlanId: StringFilter(in_: planData.classPlanIds), + status: const ClassStatusFilter( + in_: [ + ClassStatus.scheduled, + ClassStatus.inProgress, + ClassStatus.completed, + ], + ), + ), + include: const ClassModelInclude( + appointments: AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(user: UserInclude()), + ), + ), + ); for (final c in classes) { final appointments = c['appointments'] as List? ?? []; for (final a in appointments) { final aMap = a as Map; - final slots = aMap['slots'] as List? ?? []; + final slots = aMap['slotsOfAppointment'] as List? ?? []; for (final slot in slots) { final slotMap = slot as Map; final users = slotMap['user'] as List? ?? []; @@ -636,19 +650,22 @@ class DashboardRepository extends BaseRepository { required String consultantProfileId, Map? preloadedPlanPrices, }) async { - final earningsQuery = JsonQueryBuilder() - .model('ConsultantEarnings') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}).select( - {'consultantSharePaise': true, 'status': true}).build(); - final earningsRows = await executeQueryAsMaps(earningsQuery); + final earningsRows = await _prisma.consultantEarnings.findManyProjected( + where: ConsultantEarningsWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: const [ + ConsultantEarningsScalarField.consultantSharePaise, + ConsultantEarningsScalarField.status, + ], + ); var totalEarnings = 0.0; var pendingEarnings = 0.0; for (final row in earningsRows) { // consultantSharePaise is a BigInt column; the driver may surface it - // as int, BigInt, or String depending on magnitude + // as int, BigInt, or String depending on magnitude (ported from dev). final consultantShare = switch (row['consultantSharePaise']) { final num n => n.toDouble(), final BigInt b => b.toDouble(), @@ -674,6 +691,22 @@ class DashboardRepository extends BaseRepository { // ==================== Status Mapping ==================== + /// Convert an uppercase wire status string to the AppointmentStatus enum + AppointmentStatus _toAppointmentStatus(String status) => + AppointmentStatus.values.firstWhere((e) => e.toJson() == status); + + /// Convert an uppercase wire status string to the TrialSessionStatus enum + TrialSessionStatus _toTrialSessionStatus(String status) => + TrialSessionStatus.values.firstWhere((e) => e.toJson() == status); + + /// Convert an uppercase wire status string to the WebinarStatus enum + WebinarStatus _toWebinarStatus(String status) => + WebinarStatus.values.firstWhere((e) => e.toJson() == status); + + /// Convert an uppercase wire status string to the ClassStatus enum + ClassStatus _toClassStatus(String status) => + ClassStatus.values.firstWhere((e) => e.toJson() == status); + /// Map RequestStatus → TrialSessionStatus String? _mapRequestStatusToTrialStatus(String requestStatus) { switch (requestStatus) { diff --git a/backend/lib/database/repositories/dispute_repository.dart b/backend/lib/database/repositories/dispute_repository.dart index 7139b65..c38238b 100644 --- a/backend/lib/database/repositories/dispute_repository.dart +++ b/backend/lib/database/repositories/dispute_repository.dart @@ -1,8 +1,7 @@ import 'package:backend/database/repositories/base_repository.dart'; +import 'package:backend/utils/enum_utils.dart'; import 'package:backend/generated/index.dart'; import 'package:backend/utils/sentry_logger.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// Repository for dispute operations (MVP - read-only visibility) /// @@ -12,50 +11,51 @@ class DisputeRepository extends BaseRepository { DisputeRepository(super._executor, this._prisma); final PrismaClient _prisma; - final _uuid = const Uuid(); - /// Get dispute by gateway-specific dispute ID Future?> getDisputeByDisputeId(String disputeId) async { - return _prisma.dispute.findFirstRaw( - where: {'disputeId': disputeId}, + final result = await _prisma.dispute.findFirst( + where: DisputeWhereInput(disputeId: StringFilter(equals: disputeId)), ); + return result?.toJson(); } /// Get all disputes for a payment Future>> getDisputesByPaymentId( String paymentId, ) async { - return _prisma.dispute.findManyRaw( - where: {'paymentId': paymentId}, + final results = await _prisma.dispute.findMany( + where: DisputeWhereInput(paymentId: StringFilter(equals: paymentId)), orderBy: {'createdAt': 'desc'}, ); + return results.map((r) => r.toJson()).toList(); } /// Get dispute by internal ID Future?> getDisputeById(String id) async { - return _prisma.dispute.findFirstRaw( - where: {'id': id}, + final result = await _prisma.dispute.findFirst( + where: DisputeWhereInput(id: StringFilter(equals: id)), ); + return result?.toJson(); } /// Get all disputes for a user (via their payments) /// Useful for "My Disputes" screen Future>> getDisputesByUserId(String userId) async { - return _prisma.dispute.findManyRaw( - where: { - 'payment': { - 'userId': userId, - }, - }, - include: { - 'payment': { - 'select': { - 'id': true, - 'amount': true, - 'currency': true, - }, - }, - }, + return _prisma.dispute.findManyProjected( + where: DisputeWhereInput( + payment: PaymentRelationFilter( + is_: PaymentWhereInput(userId: StringFilter(equals: userId)), + ), + ), + include: const DisputeInclude( + payment: PaymentInclude( + select: [ + PaymentScalarField.id, + PaymentScalarField.amount, + PaymentScalarField.currency, + ], + ), + ), orderBy: {'createdAt': 'desc'}, ); } @@ -86,32 +86,26 @@ class DisputeRepository extends BaseRepository { return existing; } - final id = _uuid.v4(); - final now = nowIso8601; - - final createQuery = - JsonQueryBuilder().model('Dispute').action(QueryAction.create).data({ - 'id': id, - 'disputeId': disputeId, - 'paymentId': paymentId, - 'amountPaise': amount, - 'currency': currency, - 'reason': reason, - 'status': status, - 'paymentGateway': paymentGateway, - if (dueBy != null) 'dueBy': dueBy.toIso8601String(), - 'isChargeRefundable': isChargeRefundable, - if (evidence != null) - 'evidence': evidence, // Json type - pass object directly - 'createdAt': now, - 'updatedAt': now, - }).build(); - try { - await executeMutation(createQuery); + // id/timestamps autofilled; wire strings mapped to generated enums. + final created = await _prisma.dispute.create( + data: CreateDisputeInput( + disputeId: disputeId, + paymentId: paymentId, + amountPaise: BigInt.from(amount), + currency: enumFromWire(Currency.values, currency, field: 'currency'), + reason: reason, + status: enumFromWire(DisputeStatus.values, status, field: 'status'), + paymentGateway: enumFromWire(PaymentGateway.values, paymentGateway, + field: 'paymentGateway'), + dueBy: dueBy, + isChargeRefundable: isChargeRefundable, + evidence: evidence, + ), + ); return { - 'id': id, + 'id': created.id, 'disputeId': disputeId, 'paymentId': paymentId, 'amount': amount, @@ -137,14 +131,13 @@ class DisputeRepository extends BaseRepository { required String disputeId, required String status, }) async { - final updateQuery = JsonQueryBuilder() - .model('Dispute') - .action(QueryAction.update) - .where({'disputeId': disputeId}).data({ - 'status': status, - 'updatedAt': nowIso8601, - }).build(); - - await executeMutation(updateQuery); + // updateMany keeps the old silent-if-missing semantics (typed update + // throws when no row matches); updatedAt auto-refreshes. + await _prisma.dispute.updateMany( + where: DisputeWhereInput(disputeId: StringFilter(equals: disputeId)), + data: UpdateDisputeInput( + status: DisputeStatus.values.firstWhere((e) => e.toJson() == status), + ), + ); } } diff --git a/backend/lib/database/repositories/domain_repository.dart b/backend/lib/database/repositories/domain_repository.dart index d9f9aff..fa7d8e3 100644 --- a/backend/lib/database/repositories/domain_repository.dart +++ b/backend/lib/database/repositories/domain_repository.dart @@ -14,16 +14,12 @@ class DomainRepository extends BaseRepository { DomainRepository(super._executor, this._prisma); final PrismaClient _prisma; - /// Get all domains using the connector's findMany - /// - /// This replaces the raw SQL approach with the type-safe query builder. + /// Get all domains using typed delegates. Future>> findAll() async { - final query = JsonQueryBuilder() - .model('Domain') - .action(QueryAction.findMany) - .orderBy({'name': 'asc'}).build(); - - return executeQueryAsMaps(query); + final domains = await _prisma.domain.findMany( + orderBy: DomainOrderByInput(name: SortOrder.asc), + ); + return domains.map((d) => d.toJson()).toList(); } /// Get all domains with their subdomains using a single JOIN query @@ -34,15 +30,13 @@ class DomainRepository extends BaseRepository { /// NOTE: Requires SchemaRegistry to be populated with relation metadata. /// For now, falls back to the N+1 approach if relations aren't configured. Future>> findAllWithSubDomains() async { - // Try using include for relations (single JOIN query) - // This requires the schema registry to be set up + // Typed include (single JOIN query) — hydrates subDomains into each Domain. try { - final query = JsonQueryBuilder() - .model('Domain') - .action(QueryAction.findMany) - .include({'subDomains': true}).orderBy({'name': 'asc'}).build(); - - final results = await executeQueryAsMaps(query); + final domains = await _prisma.domain.findMany( + include: DomainInclude(subDomains: SubDomainInclude()), + orderBy: DomainOrderByInput(name: SortOrder.asc), + ); + final results = domains.map((d) => d.toJson()).toList(); // If we got nested results, return them directly if (results.isNotEmpty && results.first.containsKey('subDomains')) { @@ -84,70 +78,56 @@ class DomainRepository extends BaseRepository { /// Find a domain by ID Future?> findById(String id) async { - final query = JsonQueryBuilder() - .model('Domain') - .action(QueryAction.findUnique) - .where({'id': id}).build(); - - return executeQueryAsSingleMap(query); + final domain = await _prisma.domain.findUnique( + where: DomainWhereUniqueInput(id: id), + ); + return domain?.toJson(); } /// Get all subdomains for a domain using findMany Future>> findSubDomainsByDomainId( String domainId, ) async { - final query = JsonQueryBuilder() - .model('SubDomain') - .action(QueryAction.findMany) - .where({'domainId': domainId}).orderBy({'name': 'asc'}).build(); - - return executeQueryAsMaps(query); + final subs = await _prisma.subDomain.findMany( + where: SubDomainWhereInput(domainId: StringFilter(equals: domainId)), + orderBy: SubDomainOrderByInput(name: SortOrder.asc), + ); + return subs.map((s) => s.toJson()).toList(); } /// Get ALL subdomains (used for optimized batch loading) Future>> _findAllSubDomains() async { - final query = JsonQueryBuilder() - .model('SubDomain') - .action(QueryAction.findMany) - .orderBy({'name': 'asc'}).build(); - - return executeQueryAsMaps(query); + final subs = await _prisma.subDomain.findMany( + orderBy: SubDomainOrderByInput(name: SortOrder.asc), + ); + return subs.map((s) => s.toJson()).toList(); } /// Find a subdomain by ID Future?> findSubDomainById(String id) async { - final query = JsonQueryBuilder() - .model('SubDomain') - .action(QueryAction.findUnique) - .where({'id': id}).build(); - - return executeQueryAsSingleMap(query); + final sub = await _prisma.subDomain.findUnique( + where: SubDomainWhereUniqueInput(id: id), + ); + return sub?.toJson(); } - /// Get domain count using aggregation - /// - /// Demonstrates the connector's aggregation support. + /// Get domain count. Future count() async { - final query = - JsonQueryBuilder().model('Domain').action(QueryAction.count).build(); - - return executeCount(query); + return _prisma.domain.count(); } /// Get all domains with subdomain count using computed fields /// /// Uses ComputedField.count() to generate a correlated subquery for counting. Future>> findDomainsWithSubDomainCount() async { - final query = JsonQueryBuilder() - .model('Domain') - .action(QueryAction.findMany) - .computed({ - 'subDomainCount': ComputedField.count( - from: 'SubDomain', - where: {'domainId': FieldRef('id')}, - ), - }).orderBy({'name': 'asc'}).build(); - - return executeQueryAsMaps(query); + return _prisma.domain.findManyProjected( + computed: { + 'subDomainCount': ComputedField.count( + from: 'SubDomain', + where: {'domainId': FieldRef('id')}, + ), + }, + orderBy: DomainOrderByInput(name: SortOrder.asc), + ); } } diff --git a/backend/lib/database/repositories/maintenance_repository.dart b/backend/lib/database/repositories/maintenance_repository.dart index ace547e..c3c1830 100644 --- a/backend/lib/database/repositories/maintenance_repository.dart +++ b/backend/lib/database/repositories/maintenance_repository.dart @@ -15,12 +15,19 @@ class MaintenanceRepository extends BaseRepository { /// /// Active = phase is not OFF and startedAt is set. Future?> getActive() async { - return _prisma.maintenanceWindow.findFirstRaw( - where: { - 'phase': {'not': 'OFF'}, - 'startedAt': {'not': null}, - 'endedAt': null, - }, + // The typed DateTimeFilter cannot express `IS NULL` / `IS NOT NULL`, so + // the startedAt/endedAt null checks are applied in Dart. Maintenance + // windows are a tiny table, so fetching non-OFF rows is cheap. + final windows = await _prisma.maintenanceWindow.findMany( + where: const MaintenanceWindowWhereInput( + phase: MaintenancePhaseFilter(not: MaintenancePhase.off), + ), ); + for (final window in windows) { + if (window.startedAt != null && window.endedAt == null) { + return window.toJson(); + } + } + return null; } } diff --git a/backend/lib/database/repositories/meeting_session_repository.dart b/backend/lib/database/repositories/meeting_session_repository.dart index a9eb169..3f07331 100644 --- a/backend/lib/database/repositories/meeting_session_repository.dart +++ b/backend/lib/database/repositories/meeting_session_repository.dart @@ -1,5 +1,4 @@ import 'package:backend/generated/index.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; import 'package:uuid/uuid.dart'; import 'base_repository.dart'; @@ -23,14 +22,14 @@ class MeetingSessionRepository extends BaseRepository { required String userId, }) async { // Use relation filter to check if any slot has this user - final countQuery = JsonQueryBuilder() - .model('SlotOfAppointment') - .action(QueryAction.count) - .where({ - 'appointmentId': appointmentId, - 'user': FilterOperators.some({'id': userId}), - }).build(); - final count = await executeCount(countQuery); + final count = await _prisma.slotOfAppointment.count( + where: SlotOfAppointmentWhereInput( + appointmentId: StringFilter(equals: appointmentId), + user: UserListRelationFilter( + some: UserWhereInput(id: StringFilter(equals: userId)), + ), + ), + ); return count > 0; } @@ -42,9 +41,11 @@ class MeetingSessionRepository extends BaseRepository { String appointmentId, ) async { // Step 1: Get slot IDs for this appointment, ordered by start time - final slots = await _prisma.slotOfAppointment.findManyRaw( - where: {'appointmentId': appointmentId}, - selectFields: ['id'], + final slots = await _prisma.slotOfAppointment.findManyProjected( + where: SlotOfAppointmentWhereInput( + appointmentId: StringFilter(equals: appointmentId), + ), + select: [SlotOfAppointmentScalarField.id], orderBy: {'startsAt': 'asc'}, ); if (slots.isEmpty) return null; @@ -52,10 +53,15 @@ class MeetingSessionRepository extends BaseRepository { final slotIds = slots.map((s) => s['id'] as String).toList(); // Step 2: Get meeting session for any of these slots - return _prisma.meetingSession.findFirstRaw( - where: {'slotOfAppointmentId': FilterOperators.in_(slotIds)}, - include: {'slotOfAppointment': true}, + final meeting = await _prisma.meetingSession.findFirst( + where: MeetingSessionWhereInput( + slotOfAppointmentId: StringFilter(in_: slotIds), + ), + include: const MeetingSessionInclude( + slotOfAppointment: SlotOfAppointmentInclude(), + ), ); + return meeting?.toJson(); } /// Get meeting session with detailed information for the API response @@ -72,13 +78,18 @@ class MeetingSessionRepository extends BaseRepository { final slotId = meeting['slotOfAppointmentId'] as String; // Step 2: Get slot with users (participants) - final slot = await _prisma.slotOfAppointment.findFirstRaw( - where: {'id': slotId}, - include: { - 'user': { - 'select': {'id': true, 'name': true, 'image': true, 'role': true}, - }, - }, + final slot = await _prisma.slotOfAppointment.findFirstProjected( + where: SlotOfAppointmentWhereInput(id: StringFilter(equals: slotId)), + include: const SlotOfAppointmentInclude( + user: UserInclude( + select: [ + UserScalarField.id, + UserScalarField.name, + UserScalarField.image, + UserScalarField.role, + ], + ), + ), ); // Step 3: Extract consultant and consultee from users @@ -119,33 +130,29 @@ class MeetingSessionRepository extends BaseRepository { if (existing != null) return existing; // Get first slot for this appointment (ordered by start time) - final slot = await _prisma.slotOfAppointment.findFirstRaw( - where: {'appointmentId': appointmentId}, - orderBy: {'startsAt': 'asc'}, + final slot = await _prisma.slotOfAppointment.findFirst( + where: SlotOfAppointmentWhereInput( + appointmentId: StringFilter(equals: appointmentId), + ), + orderBy: const SlotOfAppointmentOrderByInput(startsAt: SortOrder.asc), ); if (slot == null) { throw StateError('No slots found for appointment $appointmentId'); } - final slotId = slot['id'] as String; - final meetingId = _uuid.v4().replaceAll('-', ''); + final slotId = slot.id; final streamCallId = 'meeting_${_uuid.v4().replaceAll('-', '')}'; - final now = nowIso8601; - - // Create meeting session using ORM - final createQuery = JsonQueryBuilder() - .model('MeetingSession') - .action(QueryAction.create) - .data({ - 'id': meetingId, - 'streamCallId': streamCallId, - 'platform': 'STREAM', - 'slotOfAppointmentId': slotId, - 'createdAt': now, - 'updatedAt': now, - }).build(); - await executeMutation(createQuery); + + // Create meeting session (id/timestamps autofilled; platform defaults + // to STREAM on the typed create input). + await _prisma.meetingSession.create( + data: CreateMeetingSessionInput( + streamCallId: streamCallId, + slotOfAppointmentId: slotId, + hostKeys: const [], + ), + ); return (await getMeetingByAppointmentId(appointmentId))!; } @@ -169,9 +176,14 @@ class MeetingSessionRepository extends BaseRepository { Future?> getMeetingByStreamCallId( String streamCallId, ) async { - return _prisma.meetingSession.findFirstRaw( - where: {'streamCallId': streamCallId}, - include: {'slotOfAppointment': true}, + final result = await _prisma.meetingSession.findFirst( + where: MeetingSessionWhereInput( + streamCallId: StringFilter(equals: streamCallId), + ), + include: const MeetingSessionInclude( + slotOfAppointment: SlotOfAppointmentInclude(), + ), ); + return result?.toJson(); } } diff --git a/backend/lib/database/repositories/organization_repository.dart b/backend/lib/database/repositories/organization_repository.dart index 1638ca2..1de72d7 100644 --- a/backend/lib/database/repositories/organization_repository.dart +++ b/backend/lib/database/repositories/organization_repository.dart @@ -13,10 +13,14 @@ class OrganizationRepository extends BaseRepository { /// Active org memberships for a user, with the org summary attached. Future>> getMyMemberships(String userId) async { - final rows = await _prisma.membership.findManyRaw( - where: {'userId': userId, 'status': 'ACTIVE'}, - include: {'organization': true}, + final models = await _prisma.membership.findMany( + where: MembershipWhereInput( + userId: StringFilter(equals: userId), + status: const MemberStatusFilter(equals: MemberStatus.active), + ), + include: MembershipInclude(organization: OrganizationInclude()), ); + final rows = models.map((m) => m.toJson()).toList(); return rows.where((row) { final org = row['organization'] as Map?; @@ -47,31 +51,34 @@ class OrganizationRepository extends BaseRepository { Future>> getMyProgramAssignments( String userId, ) async { - final memberships = await _prisma.membership.findManyRaw( - where: {'userId': userId, 'status': 'ACTIVE'}, - include: {'organization': true}, + final membershipModels = await _prisma.membership.findMany( + where: MembershipWhereInput( + userId: StringFilter(equals: userId), + status: const MemberStatusFilter(equals: MemberStatus.active), + ), + include: MembershipInclude(organization: OrganizationInclude()), ); + final memberships = membershipModels.map((m) => m.toJson()).toList(); if (memberships.isEmpty) return []; final membershipById = { for (final m in memberships) m['id'] as String: m, }; - final assignments = await _prisma.programAssignment.findManyRaw( - where: { - 'membershipId': {'in': membershipById.keys.toList()}, - 'status': 'ACTIVE', - }, - include: { - 'program': { - 'include': { - 'licensedSeatConfig': true, - 'creditPoolConfig': true, - }, - }, - }, + final assignmentModels = await _prisma.programAssignment.findMany( + where: ProgramAssignmentWhereInput( + membershipId: StringFilter(in_: membershipById.keys.toList()), + status: const AssignmentStatusFilter(equals: AssignmentStatus.active), + ), + include: ProgramAssignmentInclude( + program: ProgramInclude( + licensedSeatConfig: LicensedSeatConfigInclude(), + creditPoolConfig: CreditPoolConfigInclude(), + ), + ), orderBy: {'periodEnd': 'asc'}, ); + final assignments = assignmentModels.map((a) => a.toJson()).toList(); final results = >[]; for (final a in assignments) { diff --git a/backend/lib/database/repositories/payout_account_repository.dart b/backend/lib/database/repositories/payout_account_repository.dart index 70e4908..1567fd8 100644 --- a/backend/lib/database/repositories/payout_account_repository.dart +++ b/backend/lib/database/repositories/payout_account_repository.dart @@ -1,8 +1,7 @@ import 'package:backend/database/repositories/base_repository.dart'; +import 'package:backend/utils/enum_utils.dart'; import 'package:backend/generated/index.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; - /// Repository for payout account operations. class PayoutAccountRepository extends BaseRepository { PayoutAccountRepository(super._executor, this._prisma); @@ -21,28 +20,25 @@ class PayoutAccountRepository extends BaseRepository { String? upiId, bool isDefault = false, }) async { - final now = nowIso8601; - final query = JsonQueryBuilder() - .model('PayoutAccount') - .action(QueryAction.create) - .data({ - 'consultantProfileId': consultantProfileId, - 'provider': provider, - 'accountType': accountType, - 'accountHolderName': accountHolderName, - 'bankName': bankName, - 'accountNumberLast4': accountNumberLast4, - 'ifscCode': ifscCode, - 'upiId': upiId, - 'isVerified': false, - 'isDefault': isDefault, - 'createdAt': now, - 'updatedAt': now, - }).build(); - - final result = await executeQueryAsSingleMap(query); - if (result == null) throw Exception('Failed to create payout account'); - return result; + // provider/accountType are enums in the schema; map the wire string to the + // enum via its @JsonValue (handles multi-word values like LEMON_SQUEEZY). + final result = await _prisma.payoutAccount.create( + data: CreatePayoutAccountInput( + consultantProfileId: consultantProfileId, + provider: + enumFromWire(PaymentGateway.values, provider, field: 'provider'), + accountType: enumFromWire(PayoutAccountType.values, accountType, + field: 'accountType'), + accountHolderName: accountHolderName, + bankName: bankName, + accountNumberLast4: accountNumberLast4, + ifscCode: ifscCode, + upiId: upiId, + isVerified: false, + isDefault: isDefault, + ), + ); + return result.toJson(); } /// Get all payout accounts for a consultant. @@ -51,8 +47,7 @@ class PayoutAccountRepository extends BaseRepository { ) async { final results = await _prisma.payoutAccount.findMany( where: PayoutAccountWhereInput( - consultantProfileId: - StringFilter(equals: consultantProfileId), + consultantProfileId: StringFilter(equals: consultantProfileId), ), ); return results.map((r) => r.toJson()).toList(); @@ -99,22 +94,19 @@ class PayoutAccountRepository extends BaseRepository { required String id, required String consultantProfileId, }) async { - await executeInTransaction((txn) async { - // Unset all defaults for this consultant - final unsetQuery = JsonQueryBuilder() - .model('PayoutAccount') - .action(QueryAction.updateMany) - .where({'consultantProfileId': consultantProfileId}).data( - {'isDefault': false, 'updatedAt': nowIso8601}).build(); - await txn.executeMutation(unsetQuery); - - // Set the new default - final setQuery = JsonQueryBuilder() - .model('PayoutAccount') - .action(QueryAction.update) - .where({'id': id}).data( - {'isDefault': true, 'updatedAt': nowIso8601}).build(); - await txn.executeMutation(setQuery); + await _prisma.$transaction((tx) async { + // Unset all defaults for this consultant, then set the new default. + // (updatedAt is auto-refreshed by the typed update.) + await tx.payoutAccount.updateMany( + where: PayoutAccountWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + data: UpdatePayoutAccountInput(isDefault: false), + ); + await tx.payoutAccount.update( + where: PayoutAccountWhereUniqueInput(id: id), + data: UpdatePayoutAccountInput(isDefault: true), + ); }); } diff --git a/backend/lib/database/repositories/plan_repository.dart b/backend/lib/database/repositories/plan_repository.dart index c04a7f4..91fe9a7 100644 --- a/backend/lib/database/repositories/plan_repository.dart +++ b/backend/lib/database/repositories/plan_repository.dart @@ -55,15 +55,19 @@ class PlanRepository extends BaseRepository { Future>> listConsultationPlans( String consultantProfileId, ) async { - return _prisma.consultationPlan.findManyRaw( - where: {'consultantProfileId': consultantProfileId}, + final results = await _prisma.consultationPlan.findMany( + where: ConsultationPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), ); + return results.map((r) => r.toJson()).toList(); } Future?> findConsultationPlan(String id) async { - return _prisma.consultationPlan.findFirstRaw( - where: {'id': id}, + final result = await _prisma.consultationPlan.findFirst( + where: ConsultationPlanWhereInput(id: StringFilter(equals: id)), ); + return result?.toJson(); } Future?> updateConsultationPlan({ @@ -75,18 +79,22 @@ class PlanRepository extends BaseRepository { String? language, String? level, }) async { - final result = await _prisma.consultationPlan.update( - where: ConsultationPlanWhereUniqueInput(id: id), + final affected = await _prisma.consultationPlan.updateMany( + where: ConsultationPlanWhereInput(id: StringFilter(equals: id)), data: UpdateConsultationPlanInput( title: title, description: description, durationInHours: durationInHours, - price: price != null ? BigInt.from(price) : null, + price: price == null ? null : BigInt.from(price), language: language, level: level, ), ); - return result.toJson(); + if (affected == 0) return null; + final result = await _prisma.consultationPlan.findFirst( + where: ConsultationPlanWhereInput(id: StringFilter(equals: id)), + ); + return result?.toJson(); } Future deleteConsultationPlan(String id) async { @@ -125,8 +133,10 @@ class PlanRepository extends BaseRepository { sessionDurationInHours: sessionDurationInHours, language: language ?? 'English', level: level ?? 'Beginner', - freeTrialEnabled: freeTrialEnabled, - freeTrialDurationMinutes: freeTrialDurationMinutes, + // Schema re-sync renamed freeTrial* -> trial* (trialPriceInPaise + // defaults to 0 = free trial, matching the old semantics). + trialEnabled: freeTrialEnabled, + trialDurationMinutes: freeTrialDurationMinutes, ), ); return result.toJson(); @@ -135,15 +145,19 @@ class PlanRepository extends BaseRepository { Future>> listSubscriptionPlans( String consultantProfileId, ) async { - return _prisma.subscriptionPlan.findManyRaw( - where: {'consultantProfileId': consultantProfileId}, + final results = await _prisma.subscriptionPlan.findMany( + where: SubscriptionPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), ); + return results.map((r) => r.toJson()).toList(); } Future?> findSubscriptionPlan(String id) async { - return _prisma.subscriptionPlan.findFirstRaw( - where: {'id': id}, + final result = await _prisma.subscriptionPlan.findFirst( + where: SubscriptionPlanWhereInput(id: StringFilter(equals: id)), ); + return result?.toJson(); } Future deleteSubscriptionPlan(String id) async { @@ -188,15 +202,19 @@ class PlanRepository extends BaseRepository { Future>> listWebinarPlans( String consultantProfileId, ) async { - return _prisma.webinarPlan.findManyRaw( - where: {'consultantProfileId': consultantProfileId}, + final results = await _prisma.webinarPlan.findMany( + where: WebinarPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), ); + return results.map((r) => r.toJson()).toList(); } Future?> findWebinarPlan(String id) async { - return _prisma.webinarPlan.findFirstRaw( - where: {'id': id}, + final result = await _prisma.webinarPlan.findFirst( + where: WebinarPlanWhereInput(id: StringFilter(equals: id)), ); + return result?.toJson(); } Future deleteWebinarPlan(String id) async { @@ -245,15 +263,19 @@ class PlanRepository extends BaseRepository { Future>> listClassPlans( String consultantProfileId, ) async { - return _prisma.classPlan.findManyRaw( - where: {'consultantProfileId': consultantProfileId}, + final results = await _prisma.classPlan.findMany( + where: ClassPlanWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), ); + return results.map((r) => r.toJson()).toList(); } Future?> findClassPlan(String id) async { - return _prisma.classPlan.findFirstRaw( - where: {'id': id}, + final result = await _prisma.classPlan.findFirst( + where: ClassPlanWhereInput(id: StringFilter(equals: id)), ); + return result?.toJson(); } Future deleteClassPlan(String id) async { diff --git a/backend/lib/database/repositories/programs_repository.dart b/backend/lib/database/repositories/programs_repository.dart index ec10def..3c5fa34 100644 --- a/backend/lib/database/repositories/programs_repository.dart +++ b/backend/lib/database/repositories/programs_repository.dart @@ -1,4 +1,4 @@ -import 'package:prisma_flutter_connector/runtime_server.dart'; +import 'package:backend/generated/index.dart'; import 'base_repository.dart'; @@ -6,7 +6,9 @@ import 'base_repository.dart'; /// /// Handles queries for browsing and booking webinars and classes. class ProgramsRepository extends BaseRepository { - ProgramsRepository(super.executor); + ProgramsRepository(super.executor, this._prisma); + + final PrismaClient _prisma; /// Find webinar plans with optional filters /// @@ -20,39 +22,31 @@ class ProgramsRepository extends BaseRepository { String sortBy = 'date', bool sortDesc = false, }) async { - // Build where clause for filtering - final where = {}; - - // Domain filter via consultantProfile relation - if (domainId != null) { - where['consultantProfile'] = FilterOperators.some({ - 'domainId': domainId, - }); - } - - if (language != null) { - where['language'] = language; - } - - if (searchQuery != null && searchQuery.isNotEmpty) { - where['OR'] = [ - { - 'title': {'contains': searchQuery, 'mode': 'insensitive'} - }, - { - 'description': {'contains': searchQuery, 'mode': 'insensitive'} - }, - ]; - } - - // Count total for pagination - final countQuery = JsonQueryBuilder() - .model('WebinarPlan') - .action(QueryAction.count) - .where(where) - .build(); - - final totalCount = await executeCount(countQuery); + // Typed where (0.8.0): to-one consultantProfile relation filter + + // case-insensitive search OR. + final where = WebinarPlanWhereInput( + consultantProfile: domainId == null + ? null + : ConsultantProfileRelationFilter( + is_: ConsultantProfileWhereInput( + domainId: StringFilter(equals: domainId), + ), + ), + language: language == null ? null : StringFilter(equals: language), + OR: (searchQuery != null && searchQuery.isNotEmpty) + ? [ + WebinarPlanWhereInput( + title: StringFilter(contains: searchQuery, mode: 'insensitive'), + ), + WebinarPlanWhereInput( + description: + StringFilter(contains: searchQuery, mode: 'insensitive'), + ), + ] + : null, + ); + + final totalCount = await _prisma.webinarPlan.count(where: where); // Determine sort field String orderByField; @@ -64,17 +58,13 @@ class ProgramsRepository extends BaseRepository { orderByField = 'createdAt'; } - // Fetch webinar plans (without includes for now - fetch relations separately) - final listQuery = JsonQueryBuilder() - .model('WebinarPlan') - .action(QueryAction.findMany) - .where(where) - .orderBy({orderByField: sortDesc ? 'desc' : 'asc'}) - .skip(page * pageSize) - .take(pageSize) - .build(); - - final webinars = await executeQueryAsMaps(listQuery); + final webinarModels = await _prisma.webinarPlan.findMany( + where: where, + orderBy: {orderByField: sortDesc ? 'desc' : 'asc'}, + skip: page * pageSize, + take: pageSize, + ); + final webinars = webinarModels.map((w) => w.toJson()).toList(); // Batch fetch all consultant profiles (fixes N+1 query issue) final profileIds = webinars @@ -90,7 +80,7 @@ class ProgramsRepository extends BaseRepository { final profileId = w['consultantProfileId'] as String?; result['consultant'] = profileId != null ? consultantsMap[profileId] : null; - result['upcomingSessions'] = []; // TODO: Fetch webinar sessions + result['upcomingSessions'] = >[]; // TODO: sessions return result; }).toList(); @@ -108,31 +98,31 @@ class ProgramsRepository extends BaseRepository { /// Find a webinar plan by ID Future?> findWebinarById(String id) async { // Fetch webinar plan - final query = JsonQueryBuilder() - .model('WebinarPlan') - .action(QueryAction.findUnique) - .where({'id': id}).build(); - - final webinar = await executeQueryAsSingleMap(query); - if (webinar == null) return null; + final webinarModel = await _prisma.webinarPlan.findUnique( + where: WebinarPlanWhereUniqueInput(id: id), + ); + if (webinarModel == null) return null; + final webinar = webinarModel.toJson(); // Fetch consultant profile with user final consultantProfileId = webinar['consultantProfileId'] as String?; Map? consultant; if (consultantProfileId != null) { - final profileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findUnique) - .where({'id': consultantProfileId}).include({ - 'user': { - 'select': {'name': true, 'image': true}, - }, - 'domain': { - 'select': {'id': true, 'name': true}, - }, - }).build(); - final profile = await executeQueryAsSingleMap(profileQuery); + // Typed include with per-relation select (0.8.0). + final profile = await _prisma.consultantProfile.findFirstProjected( + where: ConsultantProfileWhereInput( + id: StringFilter(equals: consultantProfileId), + ), + include: ConsultantProfileInclude( + user: UserInclude( + select: [UserScalarField.name, UserScalarField.image], + ), + domain: DomainInclude( + select: [DomainScalarField.id, DomainScalarField.name], + ), + ), + ); if (profile != null) { final user = profile['user'] as Map?; consultant = { @@ -163,43 +153,40 @@ class ProgramsRepository extends BaseRepository { String sortBy = 'startDate', bool sortDesc = false, }) async { - // Build where clause for filtering - final where = {}; - - // Domain filter via consultantProfile relation - if (domainId != null) { - where['consultantProfile'] = FilterOperators.some({ - 'domainId': domainId, - }); - } - - if (language != null) { - where['language'] = language; - } - - if (enrollmentOpen) { - where['enrollmentStatus'] = 'OPEN'; - } - - if (searchQuery != null && searchQuery.isNotEmpty) { - where['OR'] = [ - { - 'title': {'contains': searchQuery, 'mode': 'insensitive'} - }, - { - 'description': {'contains': searchQuery, 'mode': 'insensitive'} - }, - ]; - } - - // Count total for pagination - final countQuery = JsonQueryBuilder() - .model('ClassPlan') - .action(QueryAction.count) - .where(where) - .build(); - - final totalCount = await executeCount(countQuery); + // Typed where (0.8.0). + final where = ClassPlanWhereInput( + consultantProfile: domainId == null + ? null + : ConsultantProfileRelationFilter( + is_: ConsultantProfileWhereInput( + domainId: StringFilter(equals: domainId), + ), + ), + language: language == null ? null : StringFilter(equals: language), + // The re-synced schema dropped ClassPlan.enrollmentStatus (the old raw + // filter was silently broken). Nearest semantic: plans with at least + // one class still scheduled (enrollment effectively open). + classes: enrollmentOpen + ? const ClassModelListRelationFilter( + some: ClassModelWhereInput( + status: ClassStatusFilter(equals: ClassStatus.scheduled), + ), + ) + : null, + OR: (searchQuery != null && searchQuery.isNotEmpty) + ? [ + ClassPlanWhereInput( + title: StringFilter(contains: searchQuery, mode: 'insensitive'), + ), + ClassPlanWhereInput( + description: + StringFilter(contains: searchQuery, mode: 'insensitive'), + ), + ] + : null, + ); + + final totalCount = await _prisma.classPlan.count(where: where); // Determine sort field String orderByField; @@ -213,17 +200,13 @@ class ProgramsRepository extends BaseRepository { orderByField = 'createdAt'; } - // Fetch class plans (without includes for now - fetch relations separately) - final listQuery = JsonQueryBuilder() - .model('ClassPlan') - .action(QueryAction.findMany) - .where(where) - .orderBy({orderByField: sortDesc ? 'desc' : 'asc'}) - .skip(page * pageSize) - .take(pageSize) - .build(); - - final classes = await executeQueryAsMaps(listQuery); + final classModels = await _prisma.classPlan.findMany( + where: where, + orderBy: {orderByField: sortDesc ? 'desc' : 'asc'}, + skip: page * pageSize, + take: pageSize, + ); + final classes = classModels.map((c) => c.toJson()).toList(); // Batch fetch all consultant profiles (fixes N+1 query issue) final profileIds = classes @@ -239,7 +222,7 @@ class ProgramsRepository extends BaseRepository { final profileId = c['consultantProfileId'] as String?; result['consultant'] = profileId != null ? consultantsMap[profileId] : null; - result['curriculum'] = []; // TODO: Fetch class contents + result['curriculum'] = >[]; // TODO: class contents return result; }).toList(); @@ -257,31 +240,31 @@ class ProgramsRepository extends BaseRepository { /// Find a class plan by ID Future?> findClassById(String id) async { // Fetch class plan - final query = JsonQueryBuilder() - .model('ClassPlan') - .action(QueryAction.findUnique) - .where({'id': id}).build(); - - final classPlan = await executeQueryAsSingleMap(query); - if (classPlan == null) return null; + final classPlanModel = await _prisma.classPlan.findUnique( + where: ClassPlanWhereUniqueInput(id: id), + ); + if (classPlanModel == null) return null; + final classPlan = classPlanModel.toJson(); // Fetch consultant profile with user final consultantProfileId = classPlan['consultantProfileId'] as String?; Map? consultant; if (consultantProfileId != null) { - final profileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findUnique) - .where({'id': consultantProfileId}).include({ - 'user': { - 'select': {'name': true, 'image': true}, - }, - 'domain': { - 'select': {'id': true, 'name': true}, - }, - }).build(); - final profile = await executeQueryAsSingleMap(profileQuery); + // Typed include with per-relation select (0.8.0). + final profile = await _prisma.consultantProfile.findFirstProjected( + where: ConsultantProfileWhereInput( + id: StringFilter(equals: consultantProfileId), + ), + include: ConsultantProfileInclude( + user: UserInclude( + select: [UserScalarField.name, UserScalarField.image], + ), + domain: DomainInclude( + select: [DomainScalarField.id, DomainScalarField.name], + ), + ), + ); if (profile != null) { final user = profile['user'] as Map?; consultant = { @@ -308,18 +291,14 @@ class ProgramsRepository extends BaseRepository { ) async { if (profileIds.isEmpty) return {}; - final query = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findMany) - .where({ - 'id': {'in': profileIds}, - }).include({ - 'user': { - 'select': {'name': true, 'image': true}, - }, - }).build(); - - final profiles = await executeQueryAsMaps(query); + final profiles = await _prisma.consultantProfile.findManyProjected( + where: ConsultantProfileWhereInput(id: StringFilter(in_: profileIds)), + include: ConsultantProfileInclude( + user: UserInclude( + select: [UserScalarField.name, UserScalarField.image], + ), + ), + ); // Create lookup map final result = >{}; @@ -344,15 +323,15 @@ class ProgramsRepository extends BaseRepository { String webinarPlanId, ) async { // Step 1: Get all Webinar records for this plan - final webinarQuery = - JsonQueryBuilder().model('Webinar').action(QueryAction.findMany).where({ - 'webinarPlanId': webinarPlanId, - 'status': { - 'in': ['SCHEDULED', 'IN_PROGRESS'] - }, - }).build(); - - final webinars = await executeQueryAsMaps(webinarQuery); + final webinarModels = await _prisma.webinar.findMany( + where: WebinarWhereInput( + webinarPlanId: StringFilter(equals: webinarPlanId), + status: const WebinarStatusFilter( + in_: [WebinarStatus.scheduled, WebinarStatus.inProgress], + ), + ), + ); + final webinars = webinarModels.map((w) => w.toJson()).toList(); if (webinars.isEmpty) return []; // Create a map of webinarId -> webinar for lookup @@ -365,19 +344,13 @@ class ProgramsRepository extends BaseRepository { } // Step 2: Get all Appointments for these webinars - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'webinarId': {'in': webinarIds}, - }).include({ - 'slots': true, - 'webinar': { - 'select': {'id': true} - }, - }).build(); - - final appointments = await executeQueryAsMaps(appointmentQuery); + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput(webinarId: StringFilter(in_: webinarIds)), + include: AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(), + webinar: WebinarInclude(select: [WebinarScalarField.id]), + ), + ); // Transform to session format final sessions = >[]; @@ -397,7 +370,7 @@ class ProgramsRepository extends BaseRepository { final webinar = webinarId != null ? webinarMap[webinarId] : null; if (webinar == null) continue; - final slots = appt['slots'] as List? ?? []; + final slots = appt['slotsOfAppointment'] as List? ?? []; for (final slot in slots) { final slotMap = slot as Map; sessions.add({ @@ -430,16 +403,16 @@ class ProgramsRepository extends BaseRepository { Future>> _fetchClassSessions( String classPlanId, ) async { - // Step 1: Get all Class records for this plan - final classQuery = - JsonQueryBuilder().model('Class').action(QueryAction.findMany).where({ - 'classPlanId': classPlanId, - 'status': { - 'in': ['SCHEDULED', 'IN_PROGRESS'] - }, - }).build(); - - final classes = await executeQueryAsMaps(classQuery); + // Step 1: Get all Class records for this plan (Dart model: ClassModel) + final classModels = await _prisma.classModel.findMany( + where: ClassModelWhereInput( + classPlanId: StringFilter(equals: classPlanId), + status: const ClassStatusFilter( + in_: [ClassStatus.scheduled, ClassStatus.inProgress], + ), + ), + ); + final classes = classModels.map((c) => c.toJson()).toList(); if (classes.isEmpty) return []; // Create a map of classId -> class for lookup @@ -451,20 +424,16 @@ class ProgramsRepository extends BaseRepository { classMap[id] = c; } - // Step 2: Get all Appointments for these classes - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'classId': {'in': classIds}, - }).include({ - 'slots': true, - 'class': { - 'select': {'id': true} - }, - }).build(); - - final appointments = await executeQueryAsMaps(appointmentQuery); + // Step 2: Get all Appointments for these classes (relation renamed to + // classRef in the re-synced schema; the old raw 'class' include key was + // silently broken). + final appointments = await _prisma.appointment.findManyProjected( + where: AppointmentWhereInput(classId: StringFilter(in_: classIds)), + include: AppointmentInclude( + slotsOfAppointment: SlotOfAppointmentInclude(), + classRef: ClassModelInclude(select: [ClassModelScalarField.id]), + ), + ); // Transform to session format final sessions = >[]; @@ -472,7 +441,7 @@ class ProgramsRepository extends BaseRepository { // Get classId from response or from class relation var classId = appt['classId'] as String?; if (classId == null) { - final classData = appt['class'] as Map?; + final classData = appt['classRef'] as Map?; classId = classData?['id'] as String?; } // Fallback for single class case @@ -483,7 +452,7 @@ class ProgramsRepository extends BaseRepository { final classRecord = classId != null ? classMap[classId] : null; if (classRecord == null) continue; - final slots = appt['slots'] as List? ?? []; + final slots = appt['slotsOfAppointment'] as List? ?? []; for (final slot in slots) { final slotMap = slot as Map; sessions.add({ diff --git a/backend/lib/database/repositories/referral_repository.dart b/backend/lib/database/repositories/referral_repository.dart index 0ff2276..57865ed 100644 --- a/backend/lib/database/repositories/referral_repository.dart +++ b/backend/lib/database/repositories/referral_repository.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'package:backend/database/repositories/base_repository.dart'; import 'package:backend/generated/index.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// Repository for referral operations (ReferralCode, Referral, ReferralCredit) class ReferralRepository extends BaseRepository { @@ -21,21 +20,25 @@ class ReferralRepository extends BaseRepository { required String code, }) async { // Find the referral code (check both code and customCode) - final referralCode = await _prisma.referralCode.findFirstRaw( - where: { - 'isActive': true, - 'OR': [ - {'code': code.toUpperCase()}, - {'customCode': code.toUpperCase()}, + final referralCode = await _prisma.referralCode.findFirst( + where: ReferralCodeWhereInput( + isActive: const BooleanFilter(equals: true), + OR: [ + ReferralCodeWhereInput( + code: StringFilter(equals: code.toUpperCase()), + ), + ReferralCodeWhereInput( + customCode: StringFilter(equals: code.toUpperCase()), + ), ], - }, + ), ); if (referralCode == null) { throw Exception('Invalid or inactive referral code'); } - final referrerId = referralCode['userId'] as String; + final referrerId = referralCode.userId; // Cannot refer yourself if (referrerId == userId) { @@ -43,16 +46,15 @@ class ReferralRepository extends BaseRepository { } // Check max referrals cap - final maxReferrals = (referralCode['maxReferrals'] as num?)?.toInt(); - final totalReferrals = - (referralCode['totalReferrals'] as num?)?.toInt() ?? 0; - if (maxReferrals != null && totalReferrals >= maxReferrals) { + final maxReferrals = referralCode.maxReferrals; + final totalReferrals = referralCode.totalReferrals; + if (totalReferrals >= maxReferrals) { throw Exception('This referral code has reached its maximum uses'); } // Check if user was already referred (referredUserId is @unique) - final existingReferral = await _prisma.referral.findFirstRaw( - where: {'referredUserId': userId}, + final existingReferral = await _prisma.referral.findFirst( + where: ReferralWhereInput(referredUserId: StringFilter(equals: userId)), ); if (existingReferral != null) { @@ -60,60 +62,41 @@ class ReferralRepository extends BaseRepository { } // Transaction: create referral + increment counter + credit - return executeInTransaction((txn) async { - final referralCodeId = referralCode['id'] as String; + return _prisma.$transaction((tx) async { + final referralCodeId = referralCode.id; final refereeReward = - (referralCode['refereeReward'] as num?)?.toInt() ?? - _defaultRefereeReward; - - // Create Referral record - final createReferralQuery = JsonQueryBuilder() - .model('Referral') - .action(QueryAction.create) - .data({ - 'referralCodeId': referralCodeId, - 'referredUserId': userId, - 'status': 'SIGNED_UP', - 'signedUpAt': nowIso8601, - 'createdAt': nowIso8601, - 'updatedAt': nowIso8601, - }) - .build(); - - await txn.executeMutation(createReferralQuery); + referralCode.refereeReward?.toInt() ?? _defaultRefereeReward; - // Increment totalReferrals on ReferralCode - final updateCodeQuery = JsonQueryBuilder() - .model('ReferralCode') - .action(QueryAction.update) - .where({'id': referralCodeId}) - .data({ - 'totalReferrals': totalReferrals + 1, - }) - .build(); + // Create Referral record (status/signedUpAt/timestamps autofilled). + await tx.referral.create( + data: CreateReferralInput( + referralCodeId: referralCodeId, + referredUserId: userId, + ), + ); - await txn.executeMutation(updateCodeQuery); + // Increment totalReferrals on ReferralCode + await tx.referralCode.update( + where: ReferralCodeWhereUniqueInput(id: referralCodeId), + data: UpdateReferralCodeInput( + totalReferrals: totalReferrals + 1, + ), + ); // Create ReferralCredit for the referee (signup bonus) final expiresAt = DateTime.now().toUtc().add( const Duration(days: _creditExpiryMonths * 30), ); - final createCreditQuery = JsonQueryBuilder() - .model('ReferralCredit') - .action(QueryAction.create) - .data({ - 'userId': userId, - 'amount': refereeReward, - 'remainingAmount': refereeReward, - 'source': 'REFEREE_BONUS', - 'expiresAt': expiresAt.toIso8601String(), - 'createdAt': nowIso8601, - 'updatedAt': nowIso8601, - }) - .build(); - - await txn.executeMutation(createCreditQuery); + await tx.referralCredit.create( + data: CreateReferralCreditInput( + userId: userId, + amount: BigInt.from(refereeReward), + remainingAmount: BigInt.from(refereeReward), + source: CreditSource.refereeBonus, + expiresAt: expiresAt, + ), + ); return { 'success': true, @@ -125,9 +108,10 @@ class ReferralRepository extends BaseRepository { /// Get user's referral code Future?> getReferralCode(String userId) async { - return _prisma.referralCode.findFirstRaw( - where: {'userId': userId}, + final result = await _prisma.referralCode.findFirst( + where: ReferralCodeWhereInput(userId: StringFilter(equals: userId)), ); + return result?.toJson(); } /// Create a referral code for a user @@ -143,44 +127,34 @@ class ReferralRepository extends BaseRepository { final code = _generateCode(userName); - final query = JsonQueryBuilder() - .model('ReferralCode') - .action(QueryAction.create) - .data({ - 'userId': userId, - 'code': code, - 'referrerReward': _defaultReferrerReward, - 'refereeReward': _defaultRefereeReward, - 'isActive': true, - 'totalReferrals': 0, - 'successfulReferrals': 0, - 'totalEarned': 0, - 'createdAt': nowIso8601, - 'updatedAt': nowIso8601, - }) - .build(); - - final result = await executeQueryAsSingleMap(query); - return result!; + final result = await _prisma.referralCode.create( + data: CreateReferralCodeInput( + userId: userId, + code: code, + referrerReward: BigInt.from(_defaultReferrerReward), + refereeReward: BigInt.from(_defaultRefereeReward), + totalEarned: BigInt.zero, + ), + ); + return result.toJson(); } /// Get available (unexpired, unspent) credit balance for a user Future> getAvailableCredits(String userId) async { - final now = DateTime.now().toUtc().toIso8601String(); - - final credits = await _prisma.referralCredit.findManyRaw( - where: { - 'userId': userId, - 'remainingAmount': FilterOperators.gt(0), - 'expiresAt': FilterOperators.gt(now), - }, + final now = DateTime.now().toUtc(); + + final credits = await _prisma.referralCredit.findMany( + where: ReferralCreditWhereInput( + userId: StringFilter(equals: userId), + remainingAmount: BigIntFilter(gt: BigInt.zero), + expiresAt: DateTimeFilter(gt: now), + ), ); - final totalAvailable = credits.fold(0, (sum, credit) { - final remaining = - (credit['remainingAmount'] as num?)?.toInt() ?? 0; - return sum + remaining; - }); + final totalAvailable = credits.fold( + 0, + (sum, credit) => sum + credit.remainingAmount.toInt(), + ); return { 'totalAvailable': totalAvailable, @@ -194,9 +168,7 @@ class ReferralRepository extends BaseRepository { if (userName != null && userName.isNotEmpty) { // Clean name: uppercase alpha only, 3-6 chars - final clean = userName - .toUpperCase() - .replaceAll(RegExp('[^A-Z]'), ''); + final clean = userName.toUpperCase().replaceAll(RegExp('[^A-Z]'), ''); if (clean.length >= 3) { final base = clean.substring(0, clean.length.clamp(0, 6)); diff --git a/backend/lib/database/repositories/refund_repository.dart b/backend/lib/database/repositories/refund_repository.dart index 5c2a60b..98c8b1a 100644 --- a/backend/lib/database/repositories/refund_repository.dart +++ b/backend/lib/database/repositories/refund_repository.dart @@ -1,10 +1,7 @@ -import 'dart:convert'; - import 'package:backend/database/repositories/base_repository.dart'; +import 'package:backend/utils/enum_utils.dart'; import 'package:backend/generated/index.dart'; import 'package:backend/utils/sentry_logger.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// Repository for refund operations /// @@ -13,8 +10,6 @@ class RefundRepository extends BaseRepository { RefundRepository(super._executor, this._prisma); final PrismaClient _prisma; - final _uuid = const Uuid(); - /// Create a new refund record from webhook event /// /// Returns the created refund record or null if already exists (idempotent). @@ -34,29 +29,24 @@ class RefundRepository extends BaseRepository { return existing; // Already processed } - final id = _uuid.v4(); - final now = nowIso8601; - - final createQuery = - JsonQueryBuilder().model('Refund').action(QueryAction.create).data({ - 'id': id, - 'refundId': refundId, - 'paymentId': paymentId, - 'amountPaise': amount, - 'currency': currency, - 'status': status, - 'paymentGateway': paymentGateway, - if (reason != null) 'reason': reason, - if (metadata != null) 'metadata': jsonEncode(metadata), - 'createdAt': now, - 'updatedAt': now, - }).build(); - try { - await executeMutation(createQuery); + // id/timestamps autofilled; wire strings mapped to generated enums. + final created = await _prisma.refund.create( + data: CreateRefundInput( + refundId: refundId, + paymentId: paymentId, + amountPaise: BigInt.from(amount), + currency: enumFromWire(Currency.values, currency, field: 'currency'), + status: enumFromWire(RefundStatus.values, status, field: 'status'), + paymentGateway: enumFromWire(PaymentGateway.values, paymentGateway, + field: 'paymentGateway'), + reason: reason, + metadata: metadata, // Json column — pass the map directly + ), + ); return { - 'id': id, + 'id': created.id, 'refundId': refundId, 'paymentId': paymentId, 'amount': amount, @@ -77,17 +67,21 @@ class RefundRepository extends BaseRepository { /// Get refund by gateway-specific refund ID Future?> getRefundByRefundId(String refundId) async { - return _prisma.refund.findFirstRaw(where: {'refundId': refundId}); + final result = await _prisma.refund.findFirst( + where: RefundWhereInput(refundId: StringFilter(equals: refundId)), + ); + return result?.toJson(); } /// Get all refunds for a payment Future>> getRefundsByPaymentId( String paymentId, ) async { - return _prisma.refund.findManyRaw( - where: {'paymentId': paymentId}, + final results = await _prisma.refund.findMany( + where: RefundWhereInput(paymentId: StringFilter(equals: paymentId)), orderBy: {'createdAt': 'desc'}, ); + return results.map((r) => r.toJson()).toList(); } /// Update refund status @@ -95,19 +89,21 @@ class RefundRepository extends BaseRepository { required String refundId, required String status, }) async { - final updateQuery = JsonQueryBuilder() - .model('Refund') - .action(QueryAction.update) - .where({'refundId': refundId}).data({ - 'status': status, - 'updatedAt': nowIso8601, - }).build(); - - await executeMutation(updateQuery); + // updateMany keeps the old silent-if-missing semantics (typed update + // throws when no row matches); updatedAt auto-refreshes. + await _prisma.refund.updateMany( + where: RefundWhereInput(refundId: StringFilter(equals: refundId)), + data: UpdateRefundInput( + status: RefundStatus.values.firstWhere((e) => e.toJson() == status), + ), + ); } /// Get refund by internal ID Future?> getRefundById(String id) async { - return _prisma.refund.findFirstRaw(where: {'id': id}); + final result = await _prisma.refund.findFirst( + where: RefundWhereInput(id: StringFilter(equals: id)), + ); + return result?.toJson(); } } diff --git a/backend/lib/database/repositories/review_repository.dart b/backend/lib/database/repositories/review_repository.dart index 1870012..d0205e3 100644 --- a/backend/lib/database/repositories/review_repository.dart +++ b/backend/lib/database/repositories/review_repository.dart @@ -1,8 +1,6 @@ import 'package:backend/database/repositories/base_repository.dart'; import 'package:backend/generated/index.dart'; import 'package:backend/utils/exceptions.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// Repository for consultant review operations using Prisma ORM /// @@ -14,8 +12,6 @@ class ReviewRepository extends BaseRepository { ReviewRepository(super._executor, this._prisma); final PrismaClient _prisma; - final _uuid = const Uuid(); - /// Create a review for a consultant /// /// Creates a new review linking the consultee to a consultant. @@ -27,11 +23,11 @@ class ReviewRepository extends BaseRepository { String? reviewDescription, }) async { // Check for existing review - final existing = await _prisma.consultantReview.findFirstRaw( - where: { - 'consulteeProfileId': consulteeProfileId, - 'consultantProfileId': consultantProfileId, - }, + final existing = await _prisma.consultantReview.findFirst( + where: ConsultantReviewWhereInput( + consulteeProfileId: StringFilter(equals: consulteeProfileId), + consultantProfileId: StringFilter(equals: consultantProfileId), + ), ); if (existing != null) { @@ -41,40 +37,32 @@ class ReviewRepository extends BaseRepository { ); } - final now = nowIso8601; - final reviewId = _uuid.v4(); - // Validate rating final validRating = rating.clamp(1, 5); - final createQuery = JsonQueryBuilder() - .model('ConsultantReview') - .action(QueryAction.create) - .data({ - 'id': reviewId, - 'consulteeProfileId': consulteeProfileId, - 'consultantProfileId': consultantProfileId, - 'rating': validRating, - 'reviewDescription': reviewDescription, - 'createdAt': now, - 'updatedAt': now, - }).build(); - - await executeMutation(createQuery); + // id/createdAt/updatedAt are autofilled by schema defaults. + final created = await _prisma.consultantReview.create( + data: CreateConsultantReviewInput( + consulteeProfileId: consulteeProfileId, + consultantProfileId: consultantProfileId, + rating: validRating, + reviewDescription: reviewDescription, + ), + ); // Update consultant's average rating await _updateConsultantRating(consultantProfileId); // Return the created review - final result = await _prisma.consultantReview.findFirstRaw( - where: {'id': reviewId}, + final result = await _prisma.consultantReview.findFirst( + where: ConsultantReviewWhereInput(id: StringFilter(equals: created.id)), ); if (result == null) { throw Exception('Failed to create review'); } - return result; + return result.toJson(); } /// Check if a user has already reviewed a specific consultant @@ -108,20 +96,20 @@ class ReviewRepository extends BaseRepository { ); // Fetch reviews with consultee info - final reviews = await _prisma.consultantReview.findManyRaw( - where: {'consultantProfileId': consultantProfileId}, - include: { - 'consulteeProfile': { - 'include': {'user': true}, - }, - }, + final reviews = await _prisma.consultantReview.findMany( + where: ConsultantReviewWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + include: const ConsultantReviewInclude( + consulteeProfile: ConsulteeProfileInclude(user: UserInclude()), + ), orderBy: {'createdAt': 'desc'}, skip: offset, take: effectivePageSize, ); return { - 'reviews': reviews, + 'reviews': reviews.map((r) => r.toJson()).toList(), 'pagination': { 'page': page, 'pageSize': effectivePageSize, @@ -136,9 +124,11 @@ class ReviewRepository extends BaseRepository { /// Calculates the average from all reviews and updates the profile. Future _updateConsultantRating(String consultantProfileId) async { // Get all ratings for this consultant - final reviews = await _prisma.consultantReview.findManyRaw( - where: {'consultantProfileId': consultantProfileId}, - selectFields: ['rating'], + final reviews = await _prisma.consultantReview.findManyProjected( + where: ConsultantReviewWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + select: [ConsultantReviewScalarField.rating], ); if (reviews.isEmpty) return; @@ -150,16 +140,11 @@ class ReviewRepository extends BaseRepository { ); final averageRating = totalRating / reviews.length; - // Update consultant profile - final updateQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.update) - .where({'id': consultantProfileId}).data({ - 'rating': averageRating, - 'updatedAt': nowIso8601, - }).build(); - - await executeMutation(updateQuery); + // Update consultant profile (updatedAt auto-refreshes) + await _prisma.consultantProfile.update( + where: ConsultantProfileWhereUniqueInput(id: consultantProfileId), + data: UpdateConsultantProfileInput(rating: averageRating), + ); } /// Get a user's review for a specific consultant (if exists) @@ -167,11 +152,12 @@ class ReviewRepository extends BaseRepository { required String consulteeProfileId, required String consultantProfileId, }) async { - return _prisma.consultantReview.findFirstRaw( - where: { - 'consulteeProfileId': consulteeProfileId, - 'consultantProfileId': consultantProfileId, - }, + final result = await _prisma.consultantReview.findFirst( + where: ConsultantReviewWhereInput( + consulteeProfileId: StringFilter(equals: consulteeProfileId), + consultantProfileId: StringFilter(equals: consultantProfileId), + ), ); + return result?.toJson(); } } diff --git a/backend/lib/database/repositories/session_repository.dart b/backend/lib/database/repositories/session_repository.dart index 4c55d50..2b8bb86 100644 --- a/backend/lib/database/repositories/session_repository.dart +++ b/backend/lib/database/repositories/session_repository.dart @@ -1,7 +1,6 @@ import 'package:backend/database/repositories/base_repository.dart'; import 'package:backend/database/repositories/user_repository.dart'; import 'package:backend/generated/index.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// Repository for session-related database operations /// @@ -21,29 +20,30 @@ class SessionRepository extends BaseRepository { /// The Prisma Flutter Connector currently doesn't support the include /// option for relations. Future?> findById(String sessionId) async { - final session = await _prisma.session.findFirstRaw( - where: {'id': sessionId}, + final session = await _prisma.session.findFirst( + where: SessionWhereInput(id: StringFilter(equals: sessionId)), ); if (session == null) return null; - return _hydrateWithUser(session); + return _hydrateWithUser(session.toJson()); } /// Find session by token with user data Future?> findByToken(String token) async { - final session = await _prisma.session.findFirstRaw( - where: {'token': token}, + final session = await _prisma.session.findFirst( + where: SessionWhereInput(token: StringFilter(equals: token)), ); if (session == null) return null; - return _hydrateWithUser(session); + return _hydrateWithUser(session.toJson()); } /// List all active sessions for a user Future>> findByUserId(String userId) async { - return _prisma.session.findManyRaw( - where: {'userId': userId}, + final sessions = await _prisma.session.findMany( + where: SessionWhereInput(userId: StringFilter(equals: userId)), ); + return sessions.map((s) => s.toJson()).toList(); } /// Create a new session @@ -55,48 +55,34 @@ class SessionRepository extends BaseRepository { String? ipAddress, String? userAgent, }) async { - final data = { - 'id': id, - 'token': token, - 'userId': userId, - 'expiresAt': expiresAt.toIso8601String(), - 'createdAt': nowIso8601, - 'updatedAt': nowIso8601, - }; - if (ipAddress != null) data['ipAddress'] = ipAddress; - if (userAgent != null) data['userAgent'] = userAgent; - - final query = JsonQueryBuilder() - .model('sessions') - .action(QueryAction.create) - .data(data) - .build(); - - final result = await executeQueryAsSingleMap(query); - if (result == null) { - throw Exception('Failed to create session in database'); - } - return result; + // id/createdAt/updatedAt are autofilled by the schema defaults; callers + // should use the returned row's id (CreateSessionInput has no id param). + final result = await _prisma.session.create( + data: CreateSessionInput( + token: token, + userId: userId, + expiresAt: expiresAt, + ipAddress: ipAddress, + userAgent: userAgent, + ), + ); + return result.toJson(); } /// Delete a session by ID Future delete(String sessionId) async { - final query = JsonQueryBuilder() - .model('sessions') - .action(QueryAction.delete) - .where({'id': sessionId}).build(); - - await executeMutation(query); + // deleteMany keeps the old silent-if-missing semantics (typed delete + // throws when the row is already gone). + await _prisma.session.deleteMany( + where: SessionWhereInput(id: StringFilter(equals: sessionId)), + ); } /// Delete all sessions for a user Future deleteByUserId(String userId) async { - final query = JsonQueryBuilder() - .model('sessions') - .action(QueryAction.deleteMany) - .where({'userId': userId}).build(); - - await executeMutation(query); + await _prisma.session.deleteMany( + where: SessionWhereInput(userId: StringFilter(equals: userId)), + ); } /// Delete all sessions for a user except a specific session @@ -104,15 +90,12 @@ class SessionRepository extends BaseRepository { required String userId, required String keepSessionId, }) async { - final query = JsonQueryBuilder() - .model('sessions') - .action(QueryAction.deleteMany) - .where({ - 'userId': userId, - 'id': FilterOperators.not(keepSessionId), - }).build(); - - await executeMutation(query); + await _prisma.session.deleteMany( + where: SessionWhereInput( + userId: StringFilter(equals: userId), + id: StringFilter(not: keepSessionId), + ), + ); } /// Hydrate a session record with user data diff --git a/backend/lib/database/repositories/slot_repository.dart b/backend/lib/database/repositories/slot_repository.dart index 0eb6f1f..24f421d 100644 --- a/backend/lib/database/repositories/slot_repository.dart +++ b/backend/lib/database/repositories/slot_repository.dart @@ -1,6 +1,6 @@ import 'package:backend/database/repositories/base_repository.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; - +import 'package:backend/utils/enum_utils.dart'; +import 'package:backend/generated/index.dart'; /// Repository for consultant availability slot operations /// @@ -11,7 +11,9 @@ import 'package:prisma_flutter_connector/runtime_server.dart'; /// queries with deep relation path filtering. class SlotRepository extends BaseRepository { /// Create a slot repository with the given executor - SlotRepository(super._executor); + SlotRepository(super._executor, this._prisma); + + final PrismaClient _prisma; /// Get consultant's available time slots for a date range /// @@ -69,12 +71,10 @@ class SlotRepository extends BaseRepository { Future?> _getConsultantSchedule( String consultantProfileId, ) async { - final query = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findUnique) - .where({'id': consultantProfileId}).build(); - - return executeQueryAsSingleMap(query); + final profile = await _prisma.consultantProfile.findUnique( + where: ConsultantProfileWhereUniqueInput(id: consultantProfileId), + ); + return profile?.toJson(); } /// Get already booked slots for a date range @@ -90,46 +90,64 @@ class SlotRepository extends BaseRepository { // Path: SlotOfAppointment -> Appointment -> (Consultation|Subscription) -> Plan // Only include appointments with active statuses (exclude CANCELLED, REJECTED, EXPIRED) const activeStatuses = [ - 'PENDING', - 'APPROVED', - 'APPROVED_PENDING_PAYMENT', - 'SCHEDULED', + AppointmentStatus.pending, + AppointmentStatus.approved, + AppointmentStatus.approvedPendingPayment, + AppointmentStatus.scheduled, ]; - final query = JsonQueryBuilder() - .model('SlotOfAppointment') - .action(QueryAction.findMany) - .distinct() - .selectFields(['startsAt', 'endsAt', 'isTentative']).where({ - 'AND': [ - {'startsAt': FilterOperators.gte(startDate.toIso8601String())}, - {'startsAt': FilterOperators.lt(endDate.toIso8601String())}, + // Typed nested relation filters (0.8.0) replace the legacy + // FilterOperators.relationPath chains; findManyProjected replaces + // distinct()+selectFields(). + return _prisma.slotOfAppointment.findManyProjected( + where: SlotOfAppointmentWhereInput( + startsAt: DateTimeFilter(gte: startDate, lt: endDate), + OR: [ + // Consultation appointments: consultant AND active status + SlotOfAppointmentWhereInput( + appointment: AppointmentRelationFilter( + is_: AppointmentWhereInput( + consultation: ConsultationRelationFilter( + is_: ConsultationWhereInput( + consultationPlan: ConsultationPlanRelationFilter( + is_: ConsultationPlanWhereInput( + consultantProfileId: + StringFilter(equals: consultantProfileId), + ), + ), + status: const AppointmentStatusFilter(in_: activeStatuses), + ), + ), + ), + ), + ), + // Subscription appointments: consultant AND active status + SlotOfAppointmentWhereInput( + appointment: AppointmentRelationFilter( + is_: AppointmentWhereInput( + subscription: SubscriptionRelationFilter( + is_: SubscriptionWhereInput( + subscriptionPlan: SubscriptionPlanRelationFilter( + is_: SubscriptionPlanWhereInput( + consultantProfileId: + StringFilter(equals: consultantProfileId), + ), + ), + status: const AppointmentStatusFilter(in_: activeStatuses), + ), + ), + ), + ), + ), + ], + ), + select: [ + SlotOfAppointmentScalarField.startsAt, + SlotOfAppointmentScalarField.endsAt, + SlotOfAppointmentScalarField.isTentative, ], - 'OR': [ - // Consultation appointments: filter by consultant AND active status - FilterOperators.relationPath( - 'appointment.consultation', - { - 'consultationPlan': FilterOperators.some({ - 'consultantProfileId': consultantProfileId, - }), - 'requestStatus': FilterOperators.in_(activeStatuses), - }, - ), - // Subscription appointments: filter by consultant AND active status - FilterOperators.relationPath( - 'appointment.subscription', - { - 'subscriptionPlan': FilterOperators.some({ - 'consultantProfileId': consultantProfileId, - }), - 'requestStatus': FilterOperators.in_(activeStatuses), - }, - ), - ], - }).build(); - - return executeQueryAsMaps(query); + distinct: true, + ); } /// Get custom one-time availability slots using ORM @@ -144,23 +162,14 @@ class SlotRepository extends BaseRepository { required int durationMinutes, }) async { // Get custom availability slots within the date range using ORM - final query = JsonQueryBuilder() - .model('SlotOfAvailabilityCustom') - .action(QueryAction.findMany) - .where({ - 'consultantProfileId': consultantProfileId, - 'AND': [ - { - 'startsAt': - FilterOperators.gte(startDate.toIso8601String()), - }, - { - 'startsAt': FilterOperators.lt(endDate.toIso8601String()), - }, - ], - }).orderBy({'startsAt': 'asc'}).build(); - - final results = await executeQueryAsMaps(query); + final customSlots = await _prisma.slotOfAvailabilityCustom.findMany( + where: SlotOfAvailabilityCustomWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + startsAt: DateTimeFilter(gte: startDate, lt: endDate), + ), + orderBy: {'startsAt': 'asc'}, + ); + final results = customSlots.map((c) => c.toJson()).toList(); // Merge consecutive custom windows to allow longer duration slots final mergedResults = _mergeConsecutiveCustomWindows(results); @@ -215,14 +224,13 @@ class SlotRepository extends BaseRepository { required int durationMinutes, }) async { // Get weekly availability pattern using ORM - final query = JsonQueryBuilder() - .model('SlotOfAvailabilityWeekly') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}).orderBy( - {'startDay': 'asc'}, - ).build(); - - final weeklySlots = await executeQueryAsMaps(query); + final weeklyModels = await _prisma.slotOfAvailabilityWeekly.findMany( + where: SlotOfAvailabilityWeeklyWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + orderBy: {'startDay': 'asc'}, + ); + final weeklySlots = weeklyModels.map((w) => w.toJson()).toList(); if (weeklySlots.isEmpty) { return []; @@ -243,17 +251,15 @@ class SlotRepository extends BaseRepository { ); // Filter windows that START on this day - final dayWindows = weeklySlots - .where((s) => s['startDay'] == dayOfWeek) - .toList(); + final dayWindows = + weeklySlots.where((s) => s['startDay'] == dayOfWeek).toList(); // Also get cross-day windows that END on this day (started on previous day) // This handles overnight availability like Mon 22:00 - Tue 02:00 // when the query range starts on Tuesday final crossDayWindows = weeklySlots .where((s) => - s['endDay'] == dayOfWeek && - s['startDay'] == previousDayOfWeek) + s['endDay'] == dayOfWeek && s['startDay'] == previousDayOfWeek) .toList(); // Process cross-day windows: only the post-midnight portion @@ -528,12 +534,12 @@ class SlotRepository extends BaseRepository { Future>> listWeeklySlots( String consultantProfileId, ) async { - final query = JsonQueryBuilder() - .model('SlotOfAvailabilityWeekly') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}) - .build(); - return executeQueryAsMaps(query); + final slots = await _prisma.slotOfAvailabilityWeekly.findMany( + where: SlotOfAvailabilityWeeklyWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + ); + return slots.map((s) => s.toJson()).toList(); } /// Create a weekly availability slot. @@ -545,23 +551,17 @@ class SlotRepository extends BaseRepository { required int endTimeUtc, int utcOffsetMinutes = 0, }) async { - final now = nowIso8601; - final query = JsonQueryBuilder() - .model('SlotOfAvailabilityWeekly') - .action(QueryAction.create) - .data({ - 'consultantProfileId': consultantProfileId, - 'startDay': startDay, - 'endDay': endDay, - 'startTimeUtc': startTimeUtc, - 'endTimeUtc': endTimeUtc, - 'utcOffsetMinutes': utcOffsetMinutes, - 'createdAt': now, - 'updatedAt': now, - }).build(); - final result = await executeQueryAsSingleMap(query); - if (result == null) throw Exception('Failed to create slot'); - return result; + final created = await _prisma.slotOfAvailabilityWeekly.create( + data: CreateSlotOfAvailabilityWeeklyInput( + consultantProfileId: consultantProfileId, + startDay: enumFromWire(DayOfWeek.values, startDay, field: 'startDay'), + endDay: enumFromWire(DayOfWeek.values, endDay, field: 'endDay'), + startTimeUtc: startTimeUtc, + endTimeUtc: endTimeUtc, + utcOffsetMinutes: utcOffsetMinutes, + ), + ); + return created.toJson(); } /// Update a weekly slot. @@ -572,29 +572,34 @@ class SlotRepository extends BaseRepository { int? startTimeUtc, int? endTimeUtc, }) async { - final data = {'updatedAt': nowIso8601}; - if (startDay != null) data['startDay'] = startDay; - if (endDay != null) data['endDay'] = endDay; - if (startTimeUtc != null) data['startTimeUtc'] = startTimeUtc; - if (endTimeUtc != null) data['endTimeUtc'] = endTimeUtc; - - final query = JsonQueryBuilder() - .model('SlotOfAvailabilityWeekly') - .action(QueryAction.update) - .where({'id': id}) - .data(data) - .build(); - return executeQueryAsSingleMap(query); + // Preserve silent-if-missing semantics (typed update throws on no row). + final existing = await _prisma.slotOfAvailabilityWeekly.findUnique( + where: SlotOfAvailabilityWeeklyWhereUniqueInput(id: id), + ); + if (existing == null) return null; + + final updated = await _prisma.slotOfAvailabilityWeekly.update( + where: SlotOfAvailabilityWeeklyWhereUniqueInput(id: id), + data: UpdateSlotOfAvailabilityWeeklyInput( + startDay: startDay == null + ? null + : enumFromWire(DayOfWeek.values, startDay, field: 'startDay'), + endDay: endDay == null + ? null + : enumFromWire(DayOfWeek.values, endDay, field: 'endDay'), + startTimeUtc: startTimeUtc, + endTimeUtc: endTimeUtc, + ), + ); + return updated.toJson(); } /// Delete a weekly slot. Future deleteWeeklySlot(String id) async { - final query = JsonQueryBuilder() - .model('SlotOfAvailabilityWeekly') - .action(QueryAction.delete) - .where({'id': id}) - .build(); - await executeMutation(query); + // deleteMany keeps the old silent-if-missing semantics. + await _prisma.slotOfAvailabilityWeekly.deleteMany( + where: SlotOfAvailabilityWeeklyWhereInput(id: StringFilter(equals: id)), + ); } // =========================================================================== @@ -605,12 +610,12 @@ class SlotRepository extends BaseRepository { Future>> listCustomSlots( String consultantProfileId, ) async { - final query = JsonQueryBuilder() - .model('SlotOfAvailabilityCustom') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}) - .build(); - return executeQueryAsMaps(query); + final slots = await _prisma.slotOfAvailabilityCustom.findMany( + where: SlotOfAvailabilityCustomWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + ); + return slots.map((s) => s.toJson()).toList(); } /// Create a custom availability slot. @@ -619,20 +624,14 @@ class SlotRepository extends BaseRepository { required String startsAt, required String endsAt, }) async { - final now = nowIso8601; - final query = JsonQueryBuilder() - .model('SlotOfAvailabilityCustom') - .action(QueryAction.create) - .data({ - 'consultantProfileId': consultantProfileId, - 'startsAt': startsAt, - 'endsAt': endsAt, - 'createdAt': now, - 'updatedAt': now, - }).build(); - final result = await executeQueryAsSingleMap(query); - if (result == null) throw Exception('Failed to create slot'); - return result; + final created = await _prisma.slotOfAvailabilityCustom.create( + data: CreateSlotOfAvailabilityCustomInput( + consultantProfileId: consultantProfileId, + startsAt: DateTime.parse(startsAt), + endsAt: DateTime.parse(endsAt), + ), + ); + return created.toJson(); } /// Update a custom slot. @@ -641,26 +640,27 @@ class SlotRepository extends BaseRepository { String? startsAt, String? endsAt, }) async { - final data = {'updatedAt': nowIso8601}; - if (startsAt != null) data['startsAt'] = startsAt; - if (endsAt != null) data['endsAt'] = endsAt; - - final query = JsonQueryBuilder() - .model('SlotOfAvailabilityCustom') - .action(QueryAction.update) - .where({'id': id}) - .data(data) - .build(); - return executeQueryAsSingleMap(query); + // Preserve silent-if-missing semantics (typed update throws on no row). + final existing = await _prisma.slotOfAvailabilityCustom.findUnique( + where: SlotOfAvailabilityCustomWhereUniqueInput(id: id), + ); + if (existing == null) return null; + + final updated = await _prisma.slotOfAvailabilityCustom.update( + where: SlotOfAvailabilityCustomWhereUniqueInput(id: id), + data: UpdateSlotOfAvailabilityCustomInput( + startsAt: startsAt == null ? null : DateTime.parse(startsAt), + endsAt: endsAt == null ? null : DateTime.parse(endsAt), + ), + ); + return updated.toJson(); } /// Delete a custom slot. Future deleteCustomSlot(String id) async { - final query = JsonQueryBuilder() - .model('SlotOfAvailabilityCustom') - .action(QueryAction.delete) - .where({'id': id}) - .build(); - await executeMutation(query); + // deleteMany keeps the old silent-if-missing semantics. + await _prisma.slotOfAvailabilityCustom.deleteMany( + where: SlotOfAvailabilityCustomWhereInput(id: StringFilter(equals: id)), + ); } } diff --git a/backend/lib/database/repositories/support_ticket_repository.dart b/backend/lib/database/repositories/support_ticket_repository.dart index 166c5ce..f319796 100644 --- a/backend/lib/database/repositories/support_ticket_repository.dart +++ b/backend/lib/database/repositories/support_ticket_repository.dart @@ -1,7 +1,6 @@ import 'package:backend/database/repositories/base_repository.dart'; +import 'package:backend/utils/enum_utils.dart'; import 'package:backend/generated/index.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// Exception thrown when a record is not found or access is denied class RecordNotFoundException implements Exception { @@ -22,7 +21,6 @@ class SupportTicketRepository extends BaseRepository { SupportTicketRepository(super._executor, this._prisma); final PrismaClient _prisma; - final _uuid = const Uuid(); /// Get tickets for a user with optional status filter and pagination /// @@ -38,23 +36,20 @@ class SupportTicketRepository extends BaseRepository { final offset = page * effectivePageSize; // Build where clause - final where = { - 'userId': userId, - }; - if (status != null && status.isNotEmpty) { - where['status'] = status; - } + final where = SupportTicketWhereInput( + userId: StringFilter(equals: userId), + status: status != null && status.isNotEmpty + ? SupportTicketStatusFilter( + equals: SupportTicketStatus.values + .firstWhere((e) => e.toJson() == status), + ) + : null, + ); // Count total tickets - final countQuery = JsonQueryBuilder() - .model('SupportTicket') - .action(QueryAction.count) - .where(where) - .build(); - - final totalCount = await executeCount(countQuery); + final totalCount = await _prisma.supportTicket.count(where: where); - final tickets = await _prisma.supportTicket.findManyRaw( + final tickets = await _prisma.supportTicket.findMany( where: where, orderBy: {'createdAt': 'desc'}, skip: offset, @@ -62,7 +57,7 @@ class SupportTicketRepository extends BaseRepository { ); return { - 'tickets': tickets, + 'tickets': tickets.map((t) => t.toJson()).toList(), 'pagination': { 'page': page, 'pageSize': effectivePageSize, @@ -81,8 +76,11 @@ class SupportTicketRepository extends BaseRepository { required String userId, }) async { // Fetch ticket - final ticket = await _prisma.supportTicket.findFirstRaw( - where: {'id': ticketId, 'userId': userId}, + final ticket = await _prisma.supportTicket.findFirst( + where: SupportTicketWhereInput( + id: StringFilter(equals: ticketId), + userId: StringFilter(equals: userId), + ), ); if (ticket == null) { @@ -90,20 +88,25 @@ class SupportTicketRepository extends BaseRepository { } // Fetch responses separately - final responses = await _prisma.supportResponse.findManyRaw( - where: {'supportTicketId': ticketId, 'isInternal': false}, + final responses = await _prisma.supportResponse.findMany( + where: SupportResponseWhereInput( + supportTicketId: StringFilter(equals: ticketId), + isInternal: BooleanFilter(equals: false), + ), orderBy: {'createdAt': 'asc'}, ); // Fetch attachments separately - final attachments = await _prisma.supportTicketAttachment.findManyRaw( - where: {'ticketId': ticketId}, + final attachments = await _prisma.supportTicketAttachment.findMany( + where: SupportTicketAttachmentWhereInput( + ticketId: StringFilter(equals: ticketId), + ), ); return { - ...ticket, - 'responses': responses, - 'attachments': attachments, + ...ticket.toJson(), + 'responses': responses.map((r) => r.toJson()).toList(), + 'attachments': attachments.map((a) => a.toJson()).toList(), }; } @@ -122,45 +125,35 @@ class SupportTicketRepository extends BaseRepository { String? subscriptionId, String? paymentId, }) async { - final now = nowIso8601; - final ticketId = _uuid.v4(); - - final data = { - 'id': ticketId, - 'userId': userId, - 'title': title, - 'description': description, - 'status': 'OPEN', - 'priority': priority ?? 'MEDIUM', - 'createdAt': now, - 'updatedAt': now, - }; - - // Add optional fields - if (issueType != null) data['issueType'] = issueType; - if (category != null) data['category'] = category; - if (consultationId != null) data['consultationId'] = consultationId; - if (subscriptionId != null) data['subscriptionId'] = subscriptionId; - if (paymentId != null) data['paymentId'] = paymentId; - - final createQuery = JsonQueryBuilder() - .model('SupportTicket') - .action(QueryAction.create) - .data(data) - .build(); - - await executeMutation(createQuery); + // id/timestamps autofilled; status defaults to OPEN on the typed input. + final created = await _prisma.supportTicket.create( + data: CreateSupportTicketInput( + userId: userId, + title: title, + description: description, + priority: enumFromWire(SupportPriority.values, priority ?? 'MEDIUM', + field: 'priority'), + issueType: issueType != null + ? enumFromWire(SupportIssueType.values, issueType, + field: 'issueType') + : null, + category: category, + consultationId: consultationId, + subscriptionId: subscriptionId, + paymentId: paymentId, + ), + ); // Return the created ticket - final result = await _prisma.supportTicket.findFirstRaw( - where: {'id': ticketId}, + final result = await _prisma.supportTicket.findFirst( + where: SupportTicketWhereInput(id: StringFilter(equals: created.id)), ); if (result == null) { throw Exception('Failed to create ticket'); } - return result; + return result.toJson(); } /// Add a user response to a ticket @@ -173,78 +166,64 @@ class SupportTicketRepository extends BaseRepository { required String message, }) async { // First verify the user owns the ticket - final ticket = await _prisma.supportTicket.findFirstRaw( - where: {'id': ticketId, 'userId': userId}, + final ticket = await _prisma.supportTicket.findFirst( + where: SupportTicketWhereInput( + id: StringFilter(equals: ticketId), + userId: StringFilter(equals: userId), + ), ); if (ticket == null) { throw const RecordNotFoundException('Ticket not found or access denied'); } - final now = nowIso8601; - final responseId = _uuid.v4(); - - // Create response - final createQuery = JsonQueryBuilder() - .model('SupportResponse') - .action(QueryAction.create) - .data({ - 'id': responseId, - 'supportTicketId': ticketId, - 'userId': userId, - 'message': message, - 'isInternal': false, // User responses are never internal - 'createdAt': now, - 'updatedAt': now, - }).build(); - - await executeMutation(createQuery); - - // Update ticket updatedAt - final updateQuery = JsonQueryBuilder() - .model('SupportTicket') - .action(QueryAction.update) - .where({'id': ticketId}).data({'updatedAt': now}).build(); - - await executeMutation(updateQuery); - - final result = await _prisma.supportResponse.findFirstRaw( - where: {'id': responseId}, + // Create response (id/timestamps autofilled; isInternal defaults false — + // user responses are never internal). + final created = await _prisma.supportResponse.create( + data: CreateSupportResponseInput( + supportTicketId: ticketId, + userId: userId, + message: message, + ), + ); + + // Touch the ticket so updatedAt reflects the new response (auto-refreshed + // by the typed update even with an empty data payload). + await _prisma.supportTicket.update( + where: SupportTicketWhereUniqueInput(id: ticketId), + data: const UpdateSupportTicketInput(), + ); + + final result = await _prisma.supportResponse.findFirst( + where: SupportResponseWhereInput(id: StringFilter(equals: created.id)), ); if (result == null) { throw Exception('Failed to create response'); } - return result; + return result.toJson(); } /// Get ticket count by status for a user /// /// Useful for showing badge counts (e.g., "3 open tickets") Future> getTicketCountsByStatus(String userId) async { - final statuses = ['OPEN', 'IN_PROGRESS', 'ON_HOLD', 'RESOLVED', 'CLOSED']; final counts = {}; - for (final status in statuses) { - final query = JsonQueryBuilder() - .model('SupportTicket') - .action(QueryAction.count) - .where({ - 'userId': userId, - 'status': status, - }).build(); - - counts[status.toLowerCase()] = await executeCount(query); + for (final status in SupportTicketStatus.values) { + counts[status.toJson().toLowerCase()] = await _prisma.supportTicket.count( + where: SupportTicketWhereInput( + userId: StringFilter(equals: userId), + status: SupportTicketStatusFilter(equals: status), + ), + ); } // Also get total - final totalQuery = JsonQueryBuilder() - .model('SupportTicket') - .action(QueryAction.count) - .where({'userId': userId}).build(); - - counts['total'] = await executeCount(totalQuery); + counts['total'] = await _prisma.supportTicket.count( + where: SupportTicketWhereInput(userId: StringFilter(equals: userId)), + ); return counts; } diff --git a/backend/lib/database/repositories/trial_repository.dart b/backend/lib/database/repositories/trial_repository.dart index b0ef48c..cb978fa 100644 --- a/backend/lib/database/repositories/trial_repository.dart +++ b/backend/lib/database/repositories/trial_repository.dart @@ -1,17 +1,14 @@ import 'package:backend/database/repositories/base_repository.dart'; +import 'package:backend/utils/enum_utils.dart'; import 'package:backend/generated/index.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// Repository for trial session operations. /// -/// Uses PrismaClient typed delegates for reads (findManyRaw, findFirstRaw, -/// count). Mutations (create, update) remain on JsonQueryBuilder. +/// Uses PrismaClient typed delegates (raw reads pending a later tranche). class TrialRepository extends BaseRepository { TrialRepository(super._executor, this._prisma); final PrismaClient _prisma; - static const _uuid = Uuid(); /// Request a new trial session. Future> create({ @@ -20,24 +17,17 @@ class TrialRepository extends BaseRepository { required String subscriptionPlanId, String? notes, }) async { - final now = nowIso8601; - final query = JsonQueryBuilder() - .model('TrialSession') - .action(QueryAction.create) - .data({ - 'id': _uuid.v4(), - 'consulteeProfileId': consulteeProfileId, - 'consultantProfileId': consultantProfileId, - 'subscriptionPlanId': subscriptionPlanId, - 'notes': notes, - 'status': 'PENDING', - 'requestedAt': now, - 'createdAt': now, - 'updatedAt': now, - }).build(); - final result = await executeQueryAsSingleMap(query); - if (result == null) throw Exception('Failed to create trial'); - return result; + // id/requestedAt/timestamps autofilled; status defaults to PENDING on + // the typed create input. + final result = await _prisma.trialSession.create( + data: CreateTrialSessionInput( + consulteeProfileId: consulteeProfileId, + consultantProfileId: consultantProfileId, + subscriptionPlanId: subscriptionPlanId, + notes: notes, + ), + ); + return result.toJson(); } /// Find a trial by ID. @@ -48,42 +38,45 @@ class TrialRepository extends BaseRepository { } /// Include block for enriching trial queries with relation data. - static const _trialIncludes = { - 'consulteeProfile': { - 'include': {'user': true}, - }, - 'consultantProfile': { - 'include': {'user': true}, - }, - 'subscriptionPlan': true, - }; + static const _trialIncludes = TrialSessionInclude( + consulteeProfile: ConsulteeProfileInclude(user: UserInclude()), + consultantProfile: ConsultantProfileInclude(user: UserInclude()), + subscriptionPlan: SubscriptionPlanInclude(), + ); /// List trials for a consultant. Future>> findByConsultant( String consultantProfileId, { String? status, }) async { - final where = { - 'consultantProfileId': consultantProfileId, - }; - if (status != null) where['status'] = status; - - return _prisma.trialSession.findManyRaw( - where: where, + final results = await _prisma.trialSession.findMany( + where: TrialSessionWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + status: status == null + ? null + : TrialSessionStatusFilter( + equals: TrialSessionStatus.values + .firstWhere((e) => e.toJson() == status), + ), + ), include: _trialIncludes, orderBy: {'createdAt': 'desc'}, ); + return results.map((r) => r.toJson()).toList(); } /// List trials for a consultee. Future>> findByConsultee( String consulteeProfileId, ) async { - return _prisma.trialSession.findManyRaw( - where: {'consulteeProfileId': consulteeProfileId}, + final results = await _prisma.trialSession.findMany( + where: TrialSessionWhereInput( + consulteeProfileId: StringFilter(equals: consulteeProfileId), + ), include: _trialIncludes, orderBy: {'createdAt': 'desc'}, ); + return results.map((r) => r.toJson()).toList(); } /// Check if a trial already exists for this consultant-consultee pair. @@ -105,26 +98,26 @@ class TrialRepository extends BaseRepository { required String id, required String status, }) async { - final query = JsonQueryBuilder() - .model('TrialSession') - .action(QueryAction.update) - .where({'id': id}) - .data({ - 'status': status, - 'updatedAt': nowIso8601, - }).build(); - return executeQueryAsSingleMap(query); + final affected = await _prisma.trialSession.updateMany( + where: TrialSessionWhereInput(id: StringFilter(equals: id)), + data: UpdateTrialSessionInput( + status: + enumFromWire(TrialSessionStatus.values, status, field: 'status'), + ), + ); + if (affected == 0) return null; + final result = await _prisma.trialSession.findFirst( + where: TrialSessionWhereInput(id: StringFilter(equals: id)), + ); + return result?.toJson(); } /// Get trial stats for a consultant. Future> getStats(String consultantProfileId) async { final all = await findByConsultant(consultantProfileId); - final pending = - all.where((t) => t['status'] == 'PENDING').length; - final completed = - all.where((t) => t['status'] == 'COMPLETED').length; - final converted = - all.where((t) => t['status'] == 'CONVERTED').length; + final pending = all.where((t) => t['status'] == 'PENDING').length; + final completed = all.where((t) => t['status'] == 'COMPLETED').length; + final converted = all.where((t) => t['status'] == 'CONVERTED').length; return { 'total': all.length, 'pending': pending, diff --git a/backend/lib/database/repositories/user_repository.dart b/backend/lib/database/repositories/user_repository.dart index e74d783..40bd002 100644 --- a/backend/lib/database/repositories/user_repository.dart +++ b/backend/lib/database/repositories/user_repository.dart @@ -1,7 +1,7 @@ import 'package:backend/database/repositories/base_repository.dart'; +import 'package:backend/utils/enum_utils.dart'; import 'package:backend/generated/index.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// Repository for user-related database operations /// @@ -15,12 +15,18 @@ class UserRepository extends BaseRepository { /// Find user by email Future?> findByEmail(String email) async { - return _prisma.user.findFirstRaw(where: {'email': email}); + final result = await _prisma.user.findFirst( + where: UserWhereInput(email: StringFilter(equals: email)), + ); + return result?.toJson(); } /// Find user by ID Future?> findById(String id) async { - return _prisma.user.findFirstRaw(where: {'id': id}); + final result = await _prisma.user.findFirst( + where: UserWhereInput(id: StringFilter(equals: id)), + ); + return result?.toJson(); } /// Create a new user @@ -38,29 +44,18 @@ class UserRepository extends BaseRepository { String role = 'CONSULTEE', TransactionExecutor? txn, }) async { - final data = { - 'id': id, - 'email': email, - 'name': name, - 'image': image, - 'emailVerified': false, - 'role': role, - 'onboardingCompleted': false, - 'createdAt': nowIso8601, - 'updatedAt': nowIso8601, - }; - - final query = JsonQueryBuilder() - .model('users') - .action(QueryAction.create) - .data(data) - .build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); - if (result == null) { - throw Exception('Failed to create user in database'); - } - return result; + // id/createdAt/updatedAt are autofilled by the schema defaults; callers + // should use the returned row's id (CreateUserInput has no id param). + final delegate = txn == null ? _prisma.user : UserDelegate(txn); + final result = await delegate.create( + data: CreateUserInput( + email: email, + name: name ?? '', + image: image, + role: enumFromWire(UserRole.values, role, field: 'role'), + ), + ); + return result.toJson(); } /// Update user profile data @@ -79,32 +74,33 @@ class UserRepository extends BaseRepository { String? timezone, String? profileDisplayImage, }) async { - final data = { - 'updatedAt': nowIso8601, - }; - if (name != null) data['name'] = name; - if (image != null) data['image'] = image; - if (phone != null) data['phone'] = phone; - if (bio != null) data['bio'] = bio; - if (dateOfBirth != null) data['dateOfBirth'] = dateOfBirth; - if (gender != null) data['gender'] = gender; - if (city != null) data['city'] = city; - if (country != null) data['country'] = country; - if (address != null) data['address'] = address; - if (linkedinUrl != null) data['linkedinUrl'] = linkedinUrl; - if (timezone != null) data['timezone'] = timezone; - if (profileDisplayImage != null) { - data['profileDisplayImage'] = profileDisplayImage; - } - - final query = JsonQueryBuilder() - .model('users') - .action(QueryAction.update) - .where({'id': id}) - .data(data) - .build(); - - return executeQueryAsSingleMap(query); + // updatedAt auto-refreshes on typed update. updateMany (not update) so a + // missing row returns null instead of throwing — the route maps null to a + // 404 and typed `update` would surface as a 500. + final affected = await _prisma.user.updateMany( + where: UserWhereInput(id: StringFilter(equals: id)), + data: UpdateUserInput( + name: name, + image: image, + phone: phone, + bio: bio, + dateOfBirth: dateOfBirth != null ? DateTime.parse(dateOfBirth) : null, + gender: gender != null + ? enumFromWire(Gender.values, gender, field: 'gender') + : null, + city: city, + country: country, + address: address, + linkedinUrl: linkedinUrl, + timezone: timezone, + profileDisplayImage: profileDisplayImage, + ), + ); + if (affected == 0) return null; + final result = await _prisma.user.findFirst( + where: UserWhereInput(id: StringFilter(equals: id)), + ); + return result?.toJson(); } /// Update emailVerified status @@ -112,16 +108,15 @@ class UserRepository extends BaseRepository { required String id, required bool verified, }) async { - final query = JsonQueryBuilder() - .model('users') - .action(QueryAction.update) - .where({'id': id}) - .data({ - 'emailVerified': verified, - 'updatedAt': nowIso8601, - }).build(); - - return executeQueryAsSingleMap(query); + final affected = await _prisma.user.updateMany( + where: UserWhereInput(id: StringFilter(equals: id)), + data: UpdateUserInput(emailVerified: verified), + ); + if (affected == 0) return null; + final result = await _prisma.user.findFirst( + where: UserWhereInput(id: StringFilter(equals: id)), + ); + return result?.toJson(); } /// Update user for onboarding completion @@ -146,41 +141,35 @@ class UserRepository extends BaseRepository { String? consultantProfileId, TransactionExecutor? txn, }) async { - final data = { - 'role': role, - 'name': name, - 'onboardingCompleted': onboardingCompleted, - 'updatedAt': nowIso8601, - }; - - // Add optional fields only if provided - if (phone != null) data['phone'] = phone; - if (dateOfBirth != null) { - data['dateOfBirth'] = dateOfBirth.toIso8601String(); - } - if (gender != null) data['gender'] = gender; - if (timezone != null) data['timezone'] = timezone; - if (image != null) data['image'] = image; - if (city != null) data['city'] = city; - if (country != null) data['country'] = country; - if (address != null) data['address'] = address; - if (linkedinUrl != null) data['linkedinUrl'] = linkedinUrl; - if (bio != null) data['bio'] = bio; - if (consulteeProfileId != null) { - data['consulteeProfileId'] = consulteeProfileId; - } - if (consultantProfileId != null) { - data['consultantProfileId'] = consultantProfileId; - } - - final query = JsonQueryBuilder() - .model('users') - .action(QueryAction.update) - .where({'id': id}) - .data(data) - .build(); - - return executeQueryAsSingleMap(query, txn: txn); + // updatedAt auto-refreshes on typed update. + final delegate = txn == null ? _prisma.user : UserDelegate(txn); + final affected = await delegate.updateMany( + where: UserWhereInput(id: StringFilter(equals: id)), + data: UpdateUserInput( + role: enumFromWire(UserRole.values, role, field: 'role'), + name: name, + onboardingCompleted: onboardingCompleted, + phone: phone, + dateOfBirth: dateOfBirth, + gender: gender != null + ? enumFromWire(Gender.values, gender, field: 'gender') + : null, + timezone: timezone, + image: image, + city: city, + country: country, + address: address, + linkedinUrl: linkedinUrl, + bio: bio, + consulteeProfileId: consulteeProfileId, + consultantProfileId: consultantProfileId, + ), + ); + if (affected == 0) return null; + final result = await delegate.findFirst( + where: UserWhereInput(id: StringFilter(equals: id)), + ); + return result?.toJson(); } /// Create default CookiePreference and NotificationPreference for a user. @@ -191,52 +180,20 @@ class UserRepository extends BaseRepository { String userId, { TransactionExecutor? txn, }) async { - final now = nowIso8601; - - const uuid = Uuid(); - - // Create CookiePreference with defaults (essential: true, rest: false) - final cookieQuery = JsonQueryBuilder() - .model('cookie_preferences') - .action(QueryAction.create) - .data({ - 'id': uuid.v4(), - 'userId': userId, - 'essential': true, - 'analytics': false, - 'marketing': false, - 'functional': false, - 'consentGivenAt': now, - 'consentUpdatedAt': now, - }).build(); - - await executeMutation(cookieQuery, txn: txn); - - // Create NotificationPreference with defaults - final notifQuery = JsonQueryBuilder() - .model('notification_preferences') - .action(QueryAction.create) - .data({ - 'id': uuid.v4(), - 'userId': userId, - 'allNotifications': true, - 'inAppEnabled': true, - 'emailEnabled': true, - 'pushEnabled': false, - 'mentions': false, - 'directMessages': false, - 'updates': false, - 'appointmentReminders': true, - 'paymentNotifications': true, - 'supportUpdates': true, - 'feedbackAlerts': true, - 'trialNotifications': true, - 'subscriptionAlerts': true, - 'marketingEmails': false, - 'quietHoursEnabled': false, - }).build(); + // id/consent timestamps are autofilled by schema defaults; the boolean + // defaults on the Create inputs match the old explicit values exactly. + final cookieDelegate = + txn == null ? _prisma.cookiePreference : CookiePreferenceDelegate(txn); + await cookieDelegate.create( + data: CreateCookiePreferenceInput(userId: userId), + ); - await executeMutation(notifQuery, txn: txn); + final notifDelegate = txn == null + ? _prisma.notificationPreference + : NotificationPreferenceDelegate(txn); + await notifDelegate.create( + data: CreateNotificationPreferenceInput(userId: userId), + ); } /// Delete a user by ID (for cleanup on failed registration) diff --git a/backend/lib/database/repositories/verification_repository.dart b/backend/lib/database/repositories/verification_repository.dart index b0edea0..33c9a2b 100644 --- a/backend/lib/database/repositories/verification_repository.dart +++ b/backend/lib/database/repositories/verification_repository.dart @@ -16,12 +16,13 @@ class VerificationRepository extends BaseRepository { required String identifier, required String value, }) async { - return _prisma.verification.findFirstRaw( - where: { - 'identifier': identifier, - 'value': value, - }, + final result = await _prisma.verification.findFirst( + where: VerificationWhereInput( + identifier: StringFilter(equals: identifier), + value: StringFilter(equals: value), + ), ); + return result?.toJson(); } /// Find a verification by value and identifier prefix @@ -32,17 +33,21 @@ class VerificationRepository extends BaseRepository { required String value, required String identifierPrefix, }) async { - return _prisma.verification.findFirstRaw( - where: { - 'value': value, - 'identifier': FilterOperators.startsWith(identifierPrefix), - }, + final result = await _prisma.verification.findFirst( + where: VerificationWhereInput( + value: StringFilter(equals: value), + identifier: StringFilter(startsWith: identifierPrefix), + ), ); + return result?.toJson(); } /// Find a verification by ID Future?> findById(String id) async { - return _prisma.verification.findFirstRaw(where: {'id': id}); + final result = await _prisma.verification.findFirst( + where: VerificationWhereInput(id: StringFilter(equals: id)), + ); + return result?.toJson(); } /// Create a new verification token @@ -53,23 +58,19 @@ class VerificationRepository extends BaseRepository { required DateTime expiresAt, TransactionExecutor? txn, }) async { - final query = JsonQueryBuilder() - .model('verifications') - .action(QueryAction.create) - .data({ - 'id': id, - 'identifier': identifier, - 'value': value, - 'expiresAt': expiresAt.toIso8601String(), - 'createdAt': nowIso8601, - 'updatedAt': nowIso8601, - }).build(); - - final result = await executeQueryAsSingleMap(query, txn: txn); - if (result == null) { - throw Exception('Failed to create verification in database'); - } - return result; + // id/createdAt/updatedAt are autofilled by the schema defaults; callers + // should use the returned row's id (CreateVerificationInput has no id + // param). + final delegate = + txn == null ? _prisma.verification : VerificationDelegate(txn); + final result = await delegate.create( + data: CreateVerificationInput( + identifier: identifier, + value: value, + expiresAt: expiresAt, + ), + ); + return result.toJson(); } /// Delete a verification by ID diff --git a/backend/lib/database/repositories/waitlist_repository.dart b/backend/lib/database/repositories/waitlist_repository.dart index 6d7b90b..2e5f4ab 100644 --- a/backend/lib/database/repositories/waitlist_repository.dart +++ b/backend/lib/database/repositories/waitlist_repository.dart @@ -1,17 +1,13 @@ import 'package:backend/database/repositories/base_repository.dart'; import 'package:backend/generated/index.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// Repository for waitlist operations. /// -/// Uses JsonQueryBuilder for creates (foreign keys) and PrismaClient -/// typed delegates for reads/updates. +/// Uses PrismaClient typed delegates. class WaitlistRepository extends BaseRepository { WaitlistRepository(super._executor, this._prisma); final PrismaClient _prisma; - static const _uuid = Uuid(); /// Join a waitlist for a webinar or class. Future> join({ @@ -19,24 +15,16 @@ class WaitlistRepository extends BaseRepository { String? webinarId, String? classId, }) async { - final now = nowIso8601; - final query = JsonQueryBuilder() - .model('Waitlist') - .action(QueryAction.create) - .data({ - 'id': _uuid.v4(), - 'userId': userId, - 'webinarId': webinarId, - 'classId': classId, - 'status': 'WAITING', - 'priority': 0, - 'joinedAt': now, - 'createdAt': now, - 'updatedAt': now, - }).build(); - final result = await executeQueryAsSingleMap(query); - if (result == null) throw Exception('Failed to join waitlist'); - return result; + // id/joinedAt/timestamps autofilled; status defaults to WAITING and + // priority to 0 on the typed create input. + final result = await _prisma.waitlist.create( + data: CreateWaitlistInput( + userId: userId, + webinarId: webinarId, + classId: classId, + ), + ); + return result.toJson(); } /// Get a waitlist entry by ID. @@ -48,7 +36,10 @@ class WaitlistRepository extends BaseRepository { /// Get all waitlist entries for a user. Future>> findByUser(String userId) async { - return _prisma.waitlist.findManyRaw(where: {'userId': userId}); + final results = await _prisma.waitlist.findMany( + where: WaitlistWhereInput(userId: StringFilter(equals: userId)), + ); + return results.map((r) => r.toJson()).toList(); } /// Leave a waitlist (set status to CANCELLED). @@ -92,17 +83,12 @@ class WaitlistRepository extends BaseRepository { String? webinarId, String? classId, }) async { - final where = { - 'status': 'WAITING', - }; - if (webinarId != null) where['webinarId'] = webinarId; - if (classId != null) where['classId'] = classId; - - final query = JsonQueryBuilder() - .model('Waitlist') - .action(QueryAction.count) - .where(where) - .build(); - return executeCount(query); + return _prisma.waitlist.count( + where: WaitlistWhereInput( + status: const WaitlistStatusFilter(equals: WaitlistStatus.waiting), + webinarId: webinarId != null ? StringFilter(equals: webinarId) : null, + classId: classId != null ? StringFilter(equals: classId) : null, + ), + ); } } diff --git a/backend/lib/database/repositories/webhook_event_repository.dart b/backend/lib/database/repositories/webhook_event_repository.dart index fe62beb..8949a50 100644 --- a/backend/lib/database/repositories/webhook_event_repository.dart +++ b/backend/lib/database/repositories/webhook_event_repository.dart @@ -91,9 +91,7 @@ class WebhookEventRepository extends BaseRepository { }) async { final where = WebhookEventWhereInput( processed: const BooleanFilter(equals: false), - provider: provider != null - ? StringFilter(equals: provider) - : null, + provider: provider != null ? StringFilter(equals: provider) : null, ); final events = await _prisma.webhookEvent.findMany( diff --git a/backend/lib/route_handlers/recordings_reserved_handlers.dart b/backend/lib/route_handlers/recordings_reserved_handlers.dart index 24170d3..3595052 100644 --- a/backend/lib/route_handlers/recordings_reserved_handlers.dart +++ b/backend/lib/route_handlers/recordings_reserved_handlers.dart @@ -6,7 +6,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// Handles POST /api/stream/recordings/start. Future handleRecordingStart(RequestContext context) async { @@ -19,7 +18,9 @@ Future handleRecordingStart(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -29,7 +30,9 @@ Future handleRecordingStart(RequestContext context) async { if (callId == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'callId is required'}}, + body: { + 'error': {'message': 'callId is required'} + }, ); } @@ -48,7 +51,9 @@ Future handleRecordingStart(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to start recording'}}, + body: { + 'error': {'message': 'Failed to start recording'} + }, ); } } @@ -64,7 +69,9 @@ Future handleRecordingStop(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -74,7 +81,9 @@ Future handleRecordingStop(RequestContext context) async { if (callId == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'callId is required'}}, + body: { + 'error': {'message': 'callId is required'} + }, ); } @@ -93,7 +102,9 @@ Future handleRecordingStop(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to stop recording'}}, + body: { + 'error': {'message': 'Failed to stop recording'} + }, ); } } @@ -109,7 +120,9 @@ Future handleRecordingSync(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -131,48 +144,57 @@ Future handleRecordingSync(RequestContext context) async { final streamService = context.read(); final db = context.read(); final streamRecordings = await streamService.listRecordings(callId); - final now = DateTime.now().toUtc().toIso8601String(); + final now = DateTime.now().toUtc(); - final syncCount = await db.executeInTransaction((txn) async { + // Typed create autofills id/createdAt/updatedAt. The re-synced Recording + // model renamed fields: fileName→title, streamUrl→recordingUrl, + // duration→durationInMinutes; recordedAt is required. + final syncCount = await db.prisma.$transaction((tx) async { var count = 0; for (final rec in streamRecordings) { final recId = rec['id'] as String?; if (recId == null) continue; final streamUrl = rec['url'] as String?; final filename = rec['filename'] as String?; - - final query = JsonQueryBuilder() - .model('Recording') - .action(QueryAction.create) - .data({ - 'meetingSessionId': meetingSessionId, - 'streamRecordingId': recId, - 'streamUrl': streamUrl, - 'fileName': filename ?? 'recording-$recId', - 'status': 'AVAILABLE', - 'duration': rec['duration'] as int?, - 'fileSize': rec['file_size'] as int?, - 'createdAt': now, - 'updatedAt': now, - }).build(); - - await txn.executeMutation(query); + final fileSize = rec['file_size'] as int?; + + // Idempotent: streamRecordingId is unique, so replaying the sync for + // the same callId must not blow up the whole $transaction on a + // duplicate-key violation. + await tx.recording.upsert( + where: RecordingWhereUniqueInput(streamRecordingId: recId), + create: CreateRecordingInput( + meetingSessionId: meetingSessionId, + streamRecordingId: recId, + streamCallId: callId, + recordingUrl: streamUrl ?? '', + title: filename ?? 'recording-$recId', + status: RecordingStatus.available, + durationInMinutes: (rec['duration'] as int?) ?? 0, + fileSize: fileSize != null ? BigInt.from(fileSize) : null, + recordedAt: now, + ), + update: UpdateRecordingInput( + recordingUrl: streamUrl ?? '', + durationInMinutes: (rec['duration'] as int?) ?? 0, + fileSize: fileSize != null ? BigInt.from(fileSize) : null, + ), + ); count++; } return count; }); - final recordsQuery = JsonQueryBuilder() - .model('Recording') - .action(QueryAction.findMany) - .where({'meetingSessionId': meetingSessionId}) - .build(); - final records = await db.executor.executeQueryAsMaps(recordsQuery); + final records = await db.prisma.recording.findMany( + where: RecordingWhereInput( + meetingSessionId: StringFilter(equals: meetingSessionId), + ), + ); return Response.json( body: { 'synced': syncCount, - 'data': records.map(serializeForJson).toList(), + 'data': records.map((r) => serializeForJson(r.toJson())).toList(), }, ); } catch (e, stackTrace) { @@ -184,7 +206,9 @@ Future handleRecordingSync(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to sync recordings'}}, + body: { + 'error': {'message': 'Failed to sync recordings'} + }, ); } } diff --git a/backend/lib/route_handlers/trials_reserved_handlers.dart b/backend/lib/route_handlers/trials_reserved_handlers.dart index 15b86a4..9a6d7c0 100644 --- a/backend/lib/route_handlers/trials_reserved_handlers.dart +++ b/backend/lib/route_handlers/trials_reserved_handlers.dart @@ -16,7 +16,9 @@ Future handleTrialEligibility(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -63,7 +65,9 @@ Future handleTrialEligibility(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to check eligibility'}}, + body: { + 'error': {'message': 'Failed to check eligibility'} + }, ); } } @@ -79,7 +83,9 @@ Future handleTrialStats(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -109,7 +115,9 @@ Future handleTrialStats(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to get trial stats'}}, + body: { + 'error': {'message': 'Failed to get trial stats'} + }, ); } } diff --git a/backend/lib/route_handlers/user_reserved_handlers.dart b/backend/lib/route_handlers/user_reserved_handlers.dart index 5509787..493a2a6 100644 --- a/backend/lib/route_handlers/user_reserved_handlers.dart +++ b/backend/lib/route_handlers/user_reserved_handlers.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:backend/utils/storage_utils.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// Handles /api/user/profile-image. Future handleProfileImage(RequestContext context) async { @@ -89,16 +88,13 @@ Future _handleProfileImageDelete(RequestContext context) async { } final db = context.read(); - final query = JsonQueryBuilder() - .model('users') - .action(QueryAction.update) - .where({'id': userId}) - .data({ - 'image': null, - 'updatedAt': DateTime.now().toUtc().toIso8601String(), - }) - .build(); - await db.executor.executeMutation(query); + // 0.9.0 setNull: explicit null-clear through the typed surface + // (updatedAt auto-refreshes). + await db.prisma.user.update( + where: UserWhereUniqueInput(id: userId), + data: const UpdateUserInput(), + setNull: [UserScalarField.image], + ); return Response.json(body: {'message': 'Profile image removed'}); } catch (e, stackTrace) { @@ -205,16 +201,12 @@ Future _handleProfileDisplayImageDelete( } final db = context.read(); - final query = JsonQueryBuilder() - .model('users') - .action(QueryAction.update) - .where({'id': userId}) - .data({ - 'profileDisplayImage': null, - 'updatedAt': DateTime.now().toUtc().toIso8601String(), - }) - .build(); - await db.executor.executeMutation(query); + // 0.9.0 setNull: explicit null-clear through the typed surface. + await db.prisma.user.update( + where: UserWhereUniqueInput(id: userId), + data: const UpdateUserInput(), + setNull: [UserScalarField.profileDisplayImage], + ); return Response.json( body: {'message': 'Profile display image removed'}, diff --git a/backend/lib/services/auth/auth_service.dart b/backend/lib/services/auth/auth_service.dart index 264403d..9ea45ef 100644 --- a/backend/lib/services/auth/auth_service.dart +++ b/backend/lib/services/auth/auth_service.dart @@ -1,5 +1,4 @@ import 'package:backend/database/database_client.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; import 'package:backend/services/auth/github_oauth_service.dart'; import 'package:backend/services/auth/google_token_verifier.dart'; import 'package:backend/services/auth/jwt_service.dart'; @@ -128,48 +127,52 @@ class AuthService { // Hash password with cost 12 to match BetterAuth final hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt(logRounds: _bcryptCost)); - final userId = _uuid.v4(); - // Create user, account, and profile atomically in a transaction - final user = await _db.executeInTransaction((txn) async { + // Create user, account, and profile atomically in a transaction. + // Ids/timestamps are autofilled by the schema defaults, so the created + // row's id is used for all follow-up inserts. + final user = await _db.prisma.$transaction((tx) async { // Create user without password (BetterAuth schema) - final newUser = await _db.createUser( - id: userId, - email: email, - name: name, - executor: txn, + final newUser = await tx.user.create( + data: CreateUserInput( + email: email, + name: name ?? '', + ), ); + final newUserId = newUser.id; // Create credentials account with password - await _db.accounts.createCredentials( - id: _uuid.v4(), - userId: userId, - hashedPassword: hashedPassword, - txn: txn, + await tx.account.create( + data: CreateAccountInput( + userId: newUserId, + providerId: 'credential', + accountId: newUserId, + password: hashedPassword, + ), ); // Create consultee profile and link to user - final consulteeProfileId = _uuid.v4(); - await _db.createConsulteeProfile( - id: consulteeProfileId, - userId: userId, - executor: txn, + final profile = await tx.consulteeProfile.create( + data: CreateConsulteeProfileInput(userId: newUserId), ); - // Update user with consulteeProfileId FK - final updateQuery = JsonQueryBuilder() - .model('users') - .action(QueryAction.update) - .where({'id': userId}) - .data({'consulteeProfileId': consulteeProfileId, 'updatedAt': DateTime.now().toUtc().toIso8601String()}) - .build(); - await txn.executeMutation(updateQuery); + // Update user with consulteeProfileId FK (updatedAt auto-refreshes) + final linkedUser = await tx.user.update( + where: UserWhereUniqueInput(id: newUserId), + data: UpdateUserInput(consulteeProfileId: profile.id), + ); // Create default preferences (matches web BetterAuth databaseHooks) - await _db.users.createDefaultPreferences(userId, txn: txn); + await tx.cookiePreference.create( + data: CreateCookiePreferenceInput(userId: newUserId), + ); + await tx.notificationPreference.create( + data: CreateNotificationPreferenceInput(userId: newUserId), + ); - return newUser; + return linkedUser.toJson(); }); + final userId = user['id'] as String; // Create session (outside transaction - not critical for user creation) final session = await _createSession( @@ -276,39 +279,48 @@ class AuthService { final Map user; if (existingUser == null) { - // Create new user atomically in a transaction - final userId = _uuid.v4(); - user = await _db.executeInTransaction((txn) async { - final newUser = await _db.createUser( - id: userId, - email: email, - name: name, - image: image, - executor: txn, + // Create new user atomically in a transaction; the created row's + // autofilled id is used for all follow-up inserts. + user = await _db.prisma.$transaction((tx) async { + final newUser = await tx.user.create( + data: CreateUserInput( + email: email, + name: name ?? '', + image: image, + ), ); + final newUserId = newUser.id; // Create Google OAuth account link using BetterAuth column names - await _db.accounts.createOAuth( - id: _uuid.v4(), - userId: userId, - providerId: 'google', - accountId: providerAccountId, - accessToken: accessToken, - idToken: idToken, - txn: txn, + await tx.account.create( + data: CreateAccountInput( + userId: newUserId, + providerId: 'google', + accountId: providerAccountId, + accessToken: accessToken, + idToken: idToken, + ), ); - // Create consultee profile - await _db.createConsulteeProfile( - id: _uuid.v4(), - userId: userId, - executor: txn, + // Create consultee profile and link it back on the user (mirrors the + // email-signup path; consulteeProfileId is a User FK the app reads). + final profile = await tx.consulteeProfile.create( + data: CreateConsulteeProfileInput(userId: newUserId), + ); + final linkedUser = await tx.user.update( + where: UserWhereUniqueInput(id: newUserId), + data: UpdateUserInput(consulteeProfileId: profile.id), ); // Create default preferences (matches web BetterAuth databaseHooks) - await _db.users.createDefaultPreferences(userId, txn: txn); + await tx.cookiePreference.create( + data: CreateCookiePreferenceInput(userId: newUserId), + ); + await tx.notificationPreference.create( + data: CreateNotificationPreferenceInput(userId: newUserId), + ); - return newUser; + return linkedUser.toJson(); }); } else { // Update user info from verified token @@ -374,50 +386,58 @@ class AuthService { final Map user; if (existingUser == null) { - // Create new user atomically in a transaction - final userId = _uuid.v4(); - user = await _db.executeInTransaction((txn) async { - final newUser = await _db.createUser( - id: userId, - email: email, - name: name, - image: image, - executor: txn, + // Create new user atomically in a transaction; the created row's + // autofilled id is used for all follow-up inserts. + user = await _db.prisma.$transaction((tx) async { + final newUser = await tx.user.create( + data: CreateUserInput( + email: email, + name: name, + image: image, + ), ); + final newUserId = newUser.id; // Create GitHub OAuth account link using BetterAuth column names - await _db.accounts.createOAuth( - id: _uuid.v4(), - userId: userId, - providerId: 'github', - accountId: providerAccountId, - txn: txn, + await tx.account.create( + data: CreateAccountInput( + userId: newUserId, + providerId: 'github', + accountId: providerAccountId, + ), ); - // Create consultee profile - await _db.createConsulteeProfile( - id: _uuid.v4(), - userId: userId, - executor: txn, + // Create consultee profile and link it back on the user (mirrors the + // email-signup path; consulteeProfileId is a User FK the app reads). + final profile = await tx.consulteeProfile.create( + data: CreateConsulteeProfileInput(userId: newUserId), + ); + final linkedUser = await tx.user.update( + where: UserWhereUniqueInput(id: newUserId), + data: UpdateUserInput(consulteeProfileId: profile.id), ); // Create default preferences (matches web BetterAuth databaseHooks) - await _db.users.createDefaultPreferences(userId, txn: txn); + await tx.cookiePreference.create( + data: CreateCookiePreferenceInput(userId: newUserId), + ); + await tx.notificationPreference.create( + data: CreateNotificationPreferenceInput(userId: newUserId), + ); - return newUser; + return linkedUser.toJson(); }); } else { - // Update user info if changed - if (name != null || image != null) { - final updatedUser = await _db.updateUser( - id: existingUser['id'] as String, - name: name, - image: image, - ); - user = updatedUser ?? existingUser; - } else { - user = existingUser; - } + // Refresh the stored profile from the verified GitHub token. `name` + // always resolves (it falls back to the GitHub login), so this branch + // was unconditional — made explicit rather than guarded by a condition + // the analyzer proves is always true. + final updatedUser = await _db.updateUser( + id: existingUser['id'] as String, + name: name, + image: image, + ); + user = updatedUser ?? existingUser; } // Create session with client info for security tracking @@ -478,9 +498,8 @@ class AuthService { // onboardingCompleted can be bool, DateTime, or null - convert to bool final onboardingValue = user['onboardingCompleted']; - final isOnboardingCompleted = onboardingValue is bool - ? onboardingValue - : onboardingValue != null; + final isOnboardingCompleted = + onboardingValue is bool ? onboardingValue : onboardingValue != null; return { 'id': user['id'], diff --git a/backend/lib/services/stream_service.dart b/backend/lib/services/stream_service.dart index 02df641..b2fc0d7 100644 --- a/backend/lib/services/stream_service.dart +++ b/backend/lib/services/stream_service.dart @@ -109,41 +109,60 @@ class StreamService { required String userId, String? name, String? image, - }) async { + }) => + upsertUsers([ + { + 'id': userId, + if (name != null) 'name': name, + if (image != null) 'image': image, + } + ]); + + /// Batch-upsert users in Stream Chat (the /users endpoint natively accepts + /// many users per request). Deduplicates by id and chunks to 100 users per + /// call — one API call instead of N, which is what tripped Stream's + /// 300 UpdateUsers/min rate limit when callers looped over upsertUser. + Future upsertUsers(List> users) async { if (!isConfigured) { throw StateError('Stream API key and secret must be configured'); } + if (users.isEmpty) return; + + // Dedupe by id (later entries win so richer data overwrites bare ids). + final byId = >{}; + for (final u in users) { + final id = u['id'] as String?; + if (id == null) continue; + byId[id] = {...?byId[id], ...u}; + } final url = Uri.parse('$_streamApiBaseUrl/users'); + final ids = byId.keys.toList(); + const chunkSize = 100; // Stream's per-request user cap - // Generate server token (no user_id claim = server token) - final serverToken = _createServerToken(); - - final userData = { - 'id': userId, - }; - if (name != null) userData['name'] = name; - if (image != null) userData['image'] = image; - - final response = await http.post( - url, - headers: { - 'Content-Type': 'application/json', - 'Authorization': serverToken, - 'Stream-Auth-Type': 'jwt', - 'api_key': _apiKey, - }, - body: jsonEncode({ - 'users': { - userId: userData, + for (var i = 0; i < ids.length; i += chunkSize) { + final chunk = ids.sublist( + i, i + chunkSize > ids.length ? ids.length : i + chunkSize); + final serverToken = _createServerToken(); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Authorization': serverToken, + 'Stream-Auth-Type': 'jwt', + 'api_key': _apiKey, }, - }), - ); - - if (response.statusCode != 201 && response.statusCode != 200) { - throw Exception( - 'Failed to upsert user in Stream Chat: ${response.statusCode} - ${response.body}', + body: jsonEncode({ + 'users': {for (final id in chunk) id: byId[id]}, + }), ); + + if (response.statusCode != 201 && response.statusCode != 200) { + throw Exception( + 'Failed to upsert users in Stream Chat: ' + '${response.statusCode} - ${response.body}', + ); + } } } @@ -184,11 +203,11 @@ class StreamService { throw StateError('Stream API key and secret must be configured'); } - // First, ensure all users exist in Stream Chat - for (final userId in memberIds) { - await upsertUser(userId: userId); - } - await upsertUser(userId: createdByUserId); + // Ensure all users exist in Stream Chat — one batched call + await upsertUsers([ + for (final userId in memberIds) {'id': userId}, + {'id': createdByUserId}, + ]); final url = Uri.parse( '$_streamApiBaseUrl/channels/team/$channelId/query', @@ -232,14 +251,19 @@ class StreamService { required String channelType, required String channelId, required List memberIds, + bool ensureUsers = true, }) async { if (!isConfigured) { throw StateError('Stream API key and secret must be configured'); } - // First, ensure all users exist in Stream Chat - for (final userId in memberIds) { - await upsertUser(userId: userId); + // Ensure all users exist in Stream Chat (one batched call). Callers that + // already upserted the users pass ensureUsers: false to avoid duplicate + // UpdateUsers traffic. + if (ensureUsers) { + await upsertUsers([ + for (final userId in memberIds) {'id': userId}, + ]); } final url = Uri.parse( @@ -394,30 +418,35 @@ class StreamService { String? instructorImage, String? participantName, String? participantImage, + bool ensureUsers = true, }) async { if (!isConfigured) { throw StateError('Stream API key and secret must be configured'); } try { - // Ensure both users exist in Stream Chat first - await Future.wait([ - upsertUser( - userId: instructorUserId, - name: instructorName, - image: instructorImage, - ), - upsertUser( - userId: participantUserId, - name: participantName, - image: participantImage, - ), - ]); + // Ensure both users exist in Stream Chat first (one batched call). + // Bulk callers (e.g. fix-group-channels) pre-upsert every unique user + // once and pass ensureUsers: false. + if (ensureUsers) { + await upsertUsers([ + { + 'id': instructorUserId, + if (instructorName != null) 'name': instructorName, + if (instructorImage != null) 'image': instructorImage, + }, + { + 'id': participantUserId, + if (participantName != null) 'name': participantName, + if (participantImage != null) 'image': participantImage, + }, + ]); - SentryLogger.debug( - 'Users upserted: $instructorUserId, $participantUserId', - context: 'StreamService.getOrCreateGroupChannelAndAddMember', - ); + SentryLogger.debug( + 'Users upserted: $instructorUserId, $participantUserId', + context: 'StreamService.getOrCreateGroupChannelAndAddMember', + ); + } // Create or get the channel using /query endpoint // Note: The /query endpoint creates the channel if it doesn't exist, @@ -467,6 +496,9 @@ class StreamService { await addChannelMembers( channelType: 'team', channelId: channelId, + // Users were just upserted above (or pre-upserted by a bulk caller) — + // don't re-upsert them per channel. + ensureUsers: false, memberIds: [instructorUserId, participantUserId], ); diff --git a/backend/lib/services/stripe_service.dart b/backend/lib/services/stripe_service.dart index 7ab0821..e6135ce 100644 --- a/backend/lib/services/stripe_service.dart +++ b/backend/lib/services/stripe_service.dart @@ -108,7 +108,7 @@ class StripeService { // Compute expected signature final signedPayload = '$timestamp.$payload'; - final hmac = Hmac(sha256, utf8.encode(_webhookSecret!)); + final hmac = Hmac(sha256, utf8.encode(_webhookSecret)); final digest = hmac.convert(utf8.encode(signedPayload)); final computedSignature = digest.toString(); diff --git a/backend/lib/services/webhook_handlers.dart b/backend/lib/services/webhook_handlers.dart index 61fdba1..2686ebd 100644 --- a/backend/lib/services/webhook_handlers.dart +++ b/backend/lib/services/webhook_handlers.dart @@ -1,7 +1,6 @@ import 'package:backend/database/database_client.dart'; import 'package:backend/services/stream_service.dart'; import 'package:backend/utils/sentry_logger.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// Shared webhook handlers for payment gateway events /// @@ -209,35 +208,27 @@ class WebhookHandlers { /// Find payment by paymentIntent field Future?> _findPaymentByIntent( String paymentIntent) async { - final query = JsonQueryBuilder() - .model('Payment') - .action(QueryAction.findFirst) - .where({'paymentIntent': paymentIntent}).build(); - - return _db.executor.executeQueryAsSingleMap(query); + final payment = await _db.prisma.payment.findFirst( + where: PaymentWhereInput( + paymentIntent: StringFilter(equals: paymentIntent), + ), + ); + return payment?.toJson(); } /// Confirm booking after successful payment Future _confirmBooking(String appointmentId) async { // Get appointment to find booking with related data - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findUnique) - .where({'id': appointmentId}).include({ - 'webinar': { - 'include': {'webinarPlan': true} - }, - 'class': { - 'include': {'classPlan': true} - }, - 'slots': { - 'include': { - 'user': true, - } - }, - }).build(); - final appointment = - await _db.executor.executeQueryAsSingleMap(appointmentQuery); + // (typed AppointmentInclude; relation names follow the re-synced + // schema: classRef / slotsOfAppointment). + final appointment = await _db.prisma.appointment.findUnique( + where: AppointmentWhereUniqueInput(id: appointmentId), + include: const AppointmentInclude( + webinar: WebinarInclude(webinarPlan: WebinarPlanInclude()), + classRef: ClassModelInclude(classPlan: ClassPlanInclude()), + slotsOfAppointment: SlotOfAppointmentInclude(user: UserInclude()), + ), + ); if (appointment == null) { SentryLogger.warning( @@ -247,11 +238,11 @@ class WebhookHandlers { return; } - final consultationId = appointment['consultationId'] as String?; - final subscriptionId = appointment['subscriptionId'] as String?; - final webinarId = appointment['webinarId'] as String?; - final classId = appointment['classId'] as String?; - final appointmentType = appointment['appointmentType'] as String?; + final consultationId = appointment.consultationId; + final subscriptionId = appointment.subscriptionId; + final webinarId = appointment.webinarId; + final classId = appointment.classId; + final appointmentType = appointment.appointmentType.toJson(); if (consultationId != null) { // Update consultation status to SCHEDULED @@ -281,10 +272,11 @@ class WebhookHandlers { /// Handle webinar booking confirmation - creates group chat channel Future _handleWebinarBookingConfirmation( - Map appointment, + Appointment appointment, String webinarId, ) async { - if (_streamService == null || !_streamService!.isConfigured) { + final stream = _streamService; + if (stream == null || !stream.isConfigured) { SentryLogger.warning( 'StreamService not configured, skipping group channel creation', context: 'WebhookHandlers', @@ -293,9 +285,9 @@ class WebhookHandlers { } try { - final webinar = appointment['webinar'] as Map?; - final webinarPlan = webinar?['webinarPlan'] as Map?; - final slots = appointment['slots'] as List?; + final webinar = appointment.webinar; + final webinarPlan = webinar?.webinarPlan; + final slots = appointment.slotsOfAppointment; if (webinar == null || webinarPlan == null) { SentryLogger.warning( @@ -306,10 +298,8 @@ class WebhookHandlers { } // Get instructor info - final consultantProfileId = - webinarPlan['consultantProfileId'] as String?; - final consultantInfo = - await _getConsultantUserInfo(consultantProfileId); + final consultantProfileId = webinarPlan.consultantProfileId; + final consultantInfo = await _getConsultantUserInfo(consultantProfileId); // Get participant info from slots (enrolled users) final participantInfo = _getParticipantFromSlots(slots); @@ -323,7 +313,7 @@ class WebhookHandlers { } final channelId = 'webinar_$webinarId'; - final channelName = webinarPlan['title'] as String? ?? 'Webinar'; + final channelName = webinarPlan.title; await _streamService!.getOrCreateGroupChannelAndAddMember( channelId: channelId, @@ -355,10 +345,11 @@ class WebhookHandlers { /// Handle class booking confirmation - creates group chat channel Future _handleClassBookingConfirmation( - Map appointment, + Appointment appointment, String classId, ) async { - if (_streamService == null || !_streamService!.isConfigured) { + final stream = _streamService; + if (stream == null || !stream.isConfigured) { SentryLogger.warning( 'StreamService not configured, skipping group channel creation', context: 'WebhookHandlers', @@ -367,9 +358,9 @@ class WebhookHandlers { } try { - final classRecord = appointment['class'] as Map?; - final classPlan = classRecord?['classPlan'] as Map?; - final slots = appointment['slots'] as List?; + final classRecord = appointment.classRef; + final classPlan = classRecord?.classPlan; + final slots = appointment.slotsOfAppointment; if (classRecord == null || classPlan == null) { SentryLogger.warning( @@ -380,9 +371,8 @@ class WebhookHandlers { } // Get instructor info - final consultantProfileId = classPlan['consultantProfileId'] as String?; - final consultantInfo = - await _getConsultantUserInfo(consultantProfileId); + final consultantProfileId = classPlan.consultantProfileId; + final consultantInfo = await _getConsultantUserInfo(consultantProfileId); // Get participant info from slots (enrolled users) final participantInfo = _getParticipantFromSlots(slots); @@ -396,7 +386,7 @@ class WebhookHandlers { } final channelId = 'class_$classId'; - final channelName = classPlan['title'] as String? ?? 'Class'; + final channelName = classPlan.title; await _streamService!.getOrCreateGroupChannelAndAddMember( channelId: channelId, @@ -431,39 +421,37 @@ class WebhookHandlers { String? consultantProfileId) async { if (consultantProfileId == null) return null; - final query = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findUnique) - .where({'id': consultantProfileId}).include({'user': true}).build(); - - final profile = await _db.executor.executeQueryAsSingleMap(query); + final profile = await _db.prisma.consultantProfile.findUnique( + where: ConsultantProfileWhereUniqueInput(id: consultantProfileId), + include: const ConsultantProfileInclude(user: UserInclude()), + ); if (profile == null) return null; - final user = profile['user'] as Map?; + final user = profile.user; if (user == null) return null; return { - 'userId': user['id'], - 'name': user['name'], - 'image': user['image'], + 'userId': user.id, + 'name': user.name, + 'image': user.image, }; } /// Extract participant info from appointment slots - Map? _getParticipantFromSlots(List? slots) { + Map? _getParticipantFromSlots( + List? slots) { if (slots == null || slots.isEmpty) return null; // Get users from the first slot (should all be the same for single bookings) - final firstSlot = slots.first as Map; - final users = firstSlot['user'] as List?; + final users = slots.first.user; if (users == null || users.isEmpty) return null; - final user = users.first as Map; + final user = users.first; return { - 'userId': user['id'], - 'name': user['name'], - 'image': user['image'], + 'userId': user.id, + 'name': user.name, + 'image': user.image, }; } diff --git a/backend/lib/utils/enum_utils.dart b/backend/lib/utils/enum_utils.dart new file mode 100644 index 0000000..7f93d12 --- /dev/null +++ b/backend/lib/utils/enum_utils.dart @@ -0,0 +1,26 @@ +/// Map an external enum wire string (SCREAMING_CASE `toJson()` value) to a +/// generated enum value, throwing [ArgumentError] on mismatch. +/// +/// Prefer this over `Enum.values.firstWhere((e) => e.toJson() == wire)` for +/// any value that originates from client input: the bare `firstWhere` throws a +/// `StateError` ("Bad state: No element") that surfaces as an opaque 500, +/// whereas the [ArgumentError] here carries the field name and the allowed +/// values and is mapped to a 400 by the route error handlers. +/// +/// [toWire] defaults to calling `.toJson()` via `(dynamic e) => e.toJson()`. +T enumFromWire( + List values, + String wire, { + required String field, + String Function(T)? toWire, +}) { + final encode = toWire ?? (T e) => (e as dynamic).toJson() as String; + for (final e in values) { + if (encode(e) == wire) return e; + } + throw ArgumentError.value( + wire, + field, + 'Unsupported value. Allowed: ${values.map(encode).join(', ')}', + ); +} diff --git a/backend/lib/utils/pan_crypto.dart b/backend/lib/utils/pan_crypto.dart new file mode 100644 index 0000000..1adc72e --- /dev/null +++ b/backend/lib/utils/pan_crypto.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:pointycastle/export.dart'; + +/// PAN encryption utility — AES-256-GCM. +/// +/// Wire format (identical to familiarise_web `lib/payments/tax/pan-crypto.ts` +/// so a PAN encrypted by either app is decryptable by the other): +/// [12-byte IV][ciphertext][16-byte auth tag] +/// +/// Key: `PAN_ENCRYPTION_KEY` env var (64 hex chars = 32 bytes). +/// Generate with: `openssl rand -hex 32`. +class PanCrypto { + static const int _ivLength = 12; + static const int _authTagBits = 128; // 16-byte tag + + static final Random _rng = Random.secure(); + + /// Load and validate the 32-byte key from `PAN_ENCRYPTION_KEY`. + /// + /// Throws [StateError] when the key is missing or malformed — the endpoint + /// fails closed rather than persisting a PAN in plaintext. + static Uint8List _key(String? hex) { + if (hex == null || hex.length != 64) { + throw StateError( + 'PAN_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). ' + 'Generate with: openssl rand -hex 32', + ); + } + final bytes = Uint8List(32); + for (var i = 0; i < 32; i++) { + bytes[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return bytes; + } + + /// Encrypt a PAN string into `[IV][ciphertext][tag]` bytes. + static Uint8List encrypt(String pan, {String? keyHex}) { + final key = _key(keyHex); + final iv = Uint8List.fromList( + List.generate(_ivLength, (_) => _rng.nextInt(256)), + ); + final gcm = GCMBlockCipher(AESEngine()) + ..init( + true, + AEADParameters(KeyParameter(key), _authTagBits, iv, Uint8List(0)), + ); + // pointycastle appends the 16-byte auth tag to the ciphertext, matching + // Node's `Buffer.concat([ciphertext, authTag])`. + final sealed = gcm.process(Uint8List.fromList(utf8.encode(pan))); + return Uint8List.fromList([...iv, ...sealed]); + } + + /// Decrypt `[IV][ciphertext][tag]` bytes back to the PAN string. + static String decrypt(List combined, {String? keyHex}) { + final key = _key(keyHex); + final bytes = Uint8List.fromList(combined); + final iv = bytes.sublist(0, _ivLength); + final sealed = bytes.sublist(_ivLength); + final gcm = GCMBlockCipher(AESEngine()) + ..init( + false, + AEADParameters(KeyParameter(key), _authTagBits, iv, Uint8List(0)), + ); + return utf8.decode(gcm.process(sealed)); + } +} diff --git a/backend/lib/utils/professional_background_utils.dart b/backend/lib/utils/professional_background_utils.dart index 48342bc..6dd7fff 100644 --- a/backend/lib/utils/professional_background_utils.dart +++ b/backend/lib/utils/professional_background_utils.dart @@ -104,8 +104,7 @@ class ProfessionalBackgroundUtils { final query = JsonQueryBuilder() .model(model) .action(QueryAction.deleteMany) - .where({'userId': userId}) - .build(); + .where({'userId': userId}).build(); await txn.executeMutation(query); } } diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 390bc0e..0f42b16 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1,3 +1,8 @@ +// #705 schema freeze (2026-07-17) — enterprise schema is launch-frozen. +// Additive-only until launch: new nullable columns / new models are allowed; +// renames, drops of live columns, and type changes are not. Money columns are +// BigInt paise; enums are declared below the model(s) that use them. + generator client { provider = "prisma-client-js" } @@ -60,9 +65,12 @@ model User { Waitlist Waitlist[] // Feedback and Support - feedbacks Feedback[] - supportTickets SupportTicket[] - supportResponses SupportResponse[] + feedbacks Feedback[] + supportTickets SupportTicket[] + // #appt-support — per-appointment support conversations + private CSAT. + appointmentSupportThreads AppointmentSupportThread[] + appointmentFeedback AppointmentFeedback[] + supportResponses SupportResponse[] accounts Account[] // BetterAuth Accounts sessions Session[] // BetterAuth Sessions @@ -70,6 +78,7 @@ model User { memberships Membership[] // Typed enterprise memberships (Arch 4-Modified) invitationsSent Invitation[] @relation("InvitationsSent") consentArtifacts ConsentArtifact[] + dpdpGrievances DpdpGrievance[] // Staff Dashboard Relations reportsSubmitted ModerationReport[] @relation("ReportsSubmitted") @@ -106,6 +115,14 @@ model User { // events back to a (deterministic) actor without exposing PII. pseudonymousId String? @unique + // BetterAuth admin-plugin fields (#693 moderation, starts #725 Tier-1). + // Suspension = banned:true + banExpires set (lazy expiry, plugin auto-unbans + // at sign-in); permanent ban = banned:true + banExpires:null. Who/when/why + // lives in ModerationAction, not here. + banned Boolean? @default(false) + banReason String? + banExpires DateTime? @db.Timestamptz + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -116,9 +133,12 @@ model User { /// distinguishes it from `erasureRequests` above (requests filed /// against this user) — Prisma requires the disambiguation since /// both edges target ErasureRequest. - erasureRequestsProcessed ErasureRequest[] @relation("ErasureRequestProcessor") + erasureRequestsProcessed ErasureRequest[] @relation("ErasureRequestProcessor") // Disputes this admin owns (#269). - disputesAssigned Dispute[] @relation("DisputeAssignee") + disputesAssigned Dispute[] @relation("DisputeAssignee") + // A8 — documents this consultant reviewed (back-relation for the FK-ified + // AppointmentDocument.reviewedById; named to avoid the implicit-relation guess). + documentsReviewed AppointmentDocument[] @relation("DocumentReviewer") @@index([consultantProfileId]) @@index([consulteeProfileId]) @@ -159,8 +179,11 @@ model SupportTicket { user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) userId String - responses SupportResponse[] - attachments SupportTicketAttachment[] + responses SupportResponse[] + attachments SupportTicketAttachment[] + // #appt-support — set when a per-appointment thread escalates to a human, so + // ops works it in this existing queue rather than a parallel system. + appointmentSupportThread AppointmentSupportThread? // Entity links for Swiggy-style context (link ticket to booking/payment) consultationId String? @@ -198,6 +221,7 @@ model SupportResponse { updatedAt DateTime @updatedAt @@index([supportTicketId]) + @@index([userId]) } model SupportTicketAttachment { @@ -217,6 +241,118 @@ model SupportTicketAttachment { @@index([ticketId]) } +// #appt-support — per-appointment support conversation (Swiggy/Zomato-style). +// One thread per (appointment, user). Channel-agnostic: messages are produced +// by a swappable resolver — a deterministic flowchart now, an AI agent later, +// a human on escalation — without changing this shape. organizationId is +// denormalized from the appointment so org admins can triage without a join. +model AppointmentSupportThread { + id String @id @default(uuid()) + appointmentId String + appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Null for pure B2C appointments; set for org-sponsored/hosted ones. + organizationId String? + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) + + category SupportThreadCategory @default(OTHER) + status SupportThreadStatus @default(OPEN) + // Which resolver is currently driving the thread. SELF_SERVE = flowchart. + activeChannel SupportChannel @default(SELF_SERVE) + // Current flowchart node id (null once AI/HUMAN takes over or it resolves). + currentNodeId String? + + // A HUMAN handoff creates/links a general SupportTicket so ops works it in the + // existing queue — no parallel ops system (one-to-one). + supportTicketId String? @unique + supportTicket SupportTicket? @relation(fields: [supportTicketId], references: [id], onDelete: SetNull) + + messages SupportMessage[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + resolvedAt DateTime? + + // One support conversation per (appointment, user) — reused across intents, + // like a per-order support chat. Also the concurrency guard for find-or-create. + @@unique([appointmentId, userId]) + @@index([userId, status]) + @@index([organizationId, status]) + @@index([status, createdAt]) +} + +model SupportMessage { + id String @id @default(uuid()) + threadId String + thread AppointmentSupportThread @relation(fields: [threadId], references: [id], onDelete: Cascade) + sender SupportMessageSender + body String @db.Text + // Resolver metadata — flow node id, AI model, action refs. Free-form. + metadata Json? + + createdAt DateTime @default(now()) + + @@index([threadId, createdAt]) +} + +// #appt-support — per-appointment private CSAT (1-5), distinct from the public +// ConsultantReview. Gives webinar/class a feedback path they lack today and +// feeds the org-level quality signal. One per (appointment, user). +model AppointmentFeedback { + id String @id @default(uuid()) + appointmentId String + appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organizationId String? + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) + rating Int @db.SmallInt + comment String? @db.Text + + createdAt DateTime @default(now()) + + @@unique([appointmentId, userId]) + @@index([organizationId, createdAt]) +} + +enum SupportThreadStatus { + OPEN + IN_PROGRESS + ESCALATED + RESOLVED + CLOSED +} + +// Which resolver drives the thread. The interface is identical across all three +// so a thread can move SELF_SERVE → AI → HUMAN without a data change. +enum SupportChannel { + SELF_SERVE + AI + HUMAN +} + +enum SupportMessageSender { + USER + BOT + AGENT + SYSTEM +} + +// The support intent — selects which flowchart runs. SPONSORSHIP_BILLING and +// ORG_ADMIN_DISPUTE are the B2B-only intents (offered by context). +enum SupportThreadCategory { + CANCEL_REFUND + RESCHEDULE + NO_SHOW + TECHNICAL + PAYMENT_STATUS + RECORDING_ACCESS + QUALITY_COMPLAINT + SPONSORSHIP_BILLING + ORG_ADMIN_DISPUTE + OTHER +} + enum FeedbackStatus { PENDING ACKNOWLEDGED @@ -262,6 +398,9 @@ enum CancellationReason { CONSULTANT_ISSUE TECHNICAL_ISSUE + // Moderation-initiated (#693) — staff suspend/ban bulk-cancels + MODERATION + // Other OTHER } @@ -402,6 +541,9 @@ model Session { updatedAt DateTime @updatedAt activeOrganizationId String? + // Required by the BetterAuth admin plugin's generated queries; impersonation + // itself is not enabled (#693). + impersonatedBy String? @@index([userId]) @@map("sessions") @@ -482,14 +624,10 @@ model Organization { canSponsor Boolean @default(true) canHost Boolean @default(false) - // #771 D3 — group hierarchy for conglomerate buyers (Tata/Reliance/Birla-style - // subsidiary groups). Nullable + inert until group-billing/subsidiary-scoping - // APIs ship (stubbed 501). Re-adds the parent/root columns dropped in #768 so - // a future buyer doesn't force a structural migration. Self-relation. - parentOrganizationId String? - parentOrganization Organization? @relation("OrgHierarchy", fields: [parentOrganizationId], references: [id], onDelete: SetNull) - childOrganizations Organization[] @relation("OrgHierarchy") - rootOrganizationId String? // denormalized group root for fast subsidiary scoping + // #705 freeze — inert group-hierarchy columns (parentOrganizationId, + // rootOrganizationId, OrgHierarchy self-relation) dropped: never read by any + // code path. Subsidiary scoping remains a future structural change if a + // conglomerate buyer materializes. See docs/enterprise/00-foundations/06-hierarchy.md. // India / GCC context (all schema-final; compliance logic stubbed) dataResidencyRegion DataRegion @default(IN) @@ -633,6 +771,11 @@ model Organization { // #778 §D — GST credit notes (Sec 34) issued against this org's invoices. creditNotes CreditNote[] + // #appt-support — org-scoped read views over members' per-appointment support + // threads + CSAT (denormalized org tag; org admins triage / see quality). + appointmentSupportThreads AppointmentSupportThread[] + appointmentFeedback AppointmentFeedback[] + // #778 §D — annual aggregate turnover ≥ ₹5cr makes IRN/e-invoice mandatory // (CGST e-invoicing threshold). Drives whether OrganizationInvoice must upload // to the IRP. Flag frozen now; enforcement deferred. @@ -701,6 +844,8 @@ model Invitation { // Prisma `partialIndexes` preview (buggy at 7.7.0 — prisma/prisma#29263, // #29415) reaches stable. @@index([organizationId, email, status]) + @@index([inviterId]) + @@index([userId]) @@map("invitations") } @@ -735,6 +880,13 @@ model Membership { rateCardOverrideId String? rateCardOverride RateCard? @relation("MembershipRateCardOverride", fields: [rateCardOverrideId], references: [id], onUpdate: Cascade, onDelete: SetNull) + /// ADR 18 — org-declared exclusivity for internal consultants (pairs with + /// payoutRecipient=ORGANIZATION). ENFORCED at checkout (#982): while an ACTIVE + /// membership sets this flag, the consultant's independent (non-org-owned) + /// plans cannot be booked — see checkout.ts. The "hide" half (filtering those + /// plans out of marketplace listings) remains future work per the ADR. + exclusiveEngagement Boolean @default(false) + // Entitlement tracking programAssignments ProgramAssignment[] @@ -760,6 +912,7 @@ model Membership { @@index([consulteeProfileId]) @@index([consultantProfileId]) @@index([departmentLabel]) + @@index([rateCardOverrideId]) } /// Per-organization membership role. Every value here is intentionally @@ -974,6 +1127,9 @@ model Contract { @@index([organizationId, status]) @@index([effectiveFrom, effectiveTo]) + @@index([billingAccountId]) + @@index([purchaseOrderId]) + @@index([rateCardId]) } enum ContractStatus { @@ -1121,6 +1277,9 @@ model Program { assignments ProgramAssignment[] + /// ADR 18 — unenforced curated-panel stub; empty = open network. + consultantAllowlist ProgramConsultantAllowlist[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1293,7 +1452,27 @@ model BookingUtilization { createdAt DateTime @default(now()) overageEvent OverageEvent? - @@index([programAssignmentId]) + // #781 §D — createdAt-ordered utilization scans (statements / reconcile). The + // composite also serves programAssignmentId-only lookups (leftmost prefix). + @@index([programAssignmentId, createdAt]) +} + +/// ADR 18 — optional curated consultant panel for a Program. ENFORCED at +/// checkout (#971): a Program with zero rows keeps the sponsor network open; +/// once rows exist, an org-sponsored booking's consultant must be listed or +/// the checkout is rejected under the distributed lock (see the ADR-18 +/// allowlist block in lib/payments/operations/checkout.ts). +model ProgramConsultantAllowlist { + id String @id @default(uuid()) + programId String + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + consultantProfileId String + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([programId, consultantProfileId]) + @@index([consultantProfileId]) } enum ProgramType { @@ -1466,13 +1645,14 @@ model OrganizationEarnings { consultantBpsApplied Int? status EarningStatus - holdUntil DateTime? + holdUntil DateTime? @db.Timestamptz orgPayoutId String? orgPayout OrganizationPayout? @relation(fields: [orgPayoutId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + // A6/A7/PM-16 — Timestamptz on the money models (#676). + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz @@unique([paymentId, organizationId]) // #781 §D — createdAt-ordered finance reads (payout batching, statements) @@ -1490,8 +1670,8 @@ model OrganizationPayout { status PayoutStatus paymentGateway PaymentGateway - periodStart DateTime - periodEnd DateTime + periodStart DateTime @db.Timestamptz + periodEnd DateTime @db.Timestamptz grossRevenuePaise BigInt platformFeePaise BigInt @@ -1501,7 +1681,7 @@ model OrganizationPayout { // India statutory (fields final; cron + derivation stubbed in v1) tdsSectionApplied String? // "194J" | "194O" | "194C" tdsAmountPaise BigInt? - mustPayByDate DateTime? // derived from MSME 15/45-day rule + mustPayByDate DateTime? @db.Timestamptz // derived from MSME 15/45-day rule // #781 §A — rail the batch was (or will be) submitted on. paRouteProvider PayoutRailProvider? paReferenceId String? @@ -1514,12 +1694,14 @@ model OrganizationPayout { payoutReference String? failureReason String? - processedAt DateTime? - failedAt DateTime? + processedAt DateTime? @db.Timestamptz + failedAt DateTime? @db.Timestamptz /// A1: live RazorpayX/Stripe Connect submission response. /// `gatewayPayoutId` is the gateway's `id` (Razorpay payout id or - /// Stripe transfer id). `gatewayUtr` populates only after the + /// Stripe transfer id). UTR — Unique Transaction Reference; the bank/RBI + /// settlement reference the gateway returns on a completed NEFT/IMPS/UPI + /// payout. `gatewayUtr` populates only after the /// `payout.processed`/`transfer.paid` webhook reconciles. The full /// response is stashed in `gatewayResponseRaw` for debugging. gatewayPayoutId String? @unique @@ -1530,7 +1712,7 @@ model OrganizationPayout { /// rolled into this payout is later refunded. Manual recovery only /// in v1; admin sees this in the payout-detail page. clawbackAmountPaise BigInt @default(0) - clawbackInitiatedAt DateTime? + clawbackInitiatedAt DateTime? @db.Timestamptz /// Cron-driven duplicate-guard key. The weekly batch cron derives a /// deterministic key from (organizationId, periodStart) so a retried @@ -1541,8 +1723,9 @@ model OrganizationPayout { earnings OrganizationEarnings[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + // A6/A7/PM-16 — Timestamptz on the money models (#676). + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz @@index([organizationId, status]) @@index([periodStart, periodEnd]) @@ -1594,6 +1777,9 @@ model OrganizationInvoice { status OrgInvoiceStatus @default(DRAFT) // Money + // #781 §A — invoices display the constrained Currency enum (a formal document + // shows only settleable currencies), unlike Payment.displayCurrencyAtCheckout / + // Refund.displayCurrency, which snapshot the raw buyer-facing code as free text. displayCurrency Currency fxRateUsed Decimal? inrEquivalentPaise BigInt // always captured for GST filings @@ -1683,6 +1869,9 @@ model OrganizationInvoice { @@index([organizationId, fiscalYear, issuedAt]) @@index([billingCycleStart, billingCycleEnd]) @@index([status, dueDate, markedOverdueAt]) + @@index([billingAccountId]) + @@index([contractId]) + @@index([purchaseOrderId]) } // Typed line items for OrganizationInvoice — replaces the `items` Json @@ -1840,6 +2029,7 @@ model OrgDomainClaim { verificationToken String? verifiedAt DateTime? + @@index([organizationId]) @@map("org_domain_claims") } @@ -2123,6 +2313,30 @@ model DataBreach { @@index([detectedAt]) } +/// DPDP Rule 13 grievance intake (#701, minimal). A data principal files a +/// grievance; ops sees it via a recorded system event and works it manually. +/// The redressal SLA workflow (assignment, response deadlines, closure audit) +/// is deferred — this row is the durable intake record. +model DpdpGrievance { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + subject String @db.VarChar(200) + description String @db.Text + status GrievanceStatus @default(OPEN) + createdAt DateTime @default(now()) @db.Timestamptz + resolvedAt DateTime? @db.Timestamptz + + @@index([status, createdAt]) + // Postgres does not auto-index FKs; the Cascade delete scans by userId. + @@index([userId]) +} + +enum GrievanceStatus { + OPEN + RESOLVED +} + //////////////////////////////////////////////////// USER PROFILES and SLOTTING MECHANISM //////////////////////////////////////////////////// model ConsultantProfile { @@ -2173,6 +2387,9 @@ model ConsultantProfile { trialSessions TrialSession[] activityLogs ActivityLog[] + // ADR 18 — program curated-panel stub (unenforced) + programAllowlists ProgramConsultantAllowlist[] + // Note: Professional background (workExperiences, certifications, education) // has been consolidated to User level for DRY principle @@ -2288,8 +2505,21 @@ model ConsultantReview { consulteeProfile ConsulteeProfile @relation(fields: [consulteeProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) consulteeProfileId String + // Moderation CONTENT_REMOVED soft-delete (#693); public reads filter on + // null, staff moderation surfaces keep seeing the row. + deletedAt DateTime? @db.Timestamptz + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + // One review per consultee per consultant — POST maps P2002 here to 409. + @@unique([consultantProfileId, consulteeProfileId]) + // #696 — explore "trending" sort orders ConsultantProfile by + // reviews._count; without this the per-profile aggregate seq-scans the + // whole review table. Also serves the FK join + rating>=4 social-proof reads. + // (The @@unique above already prefixes consultantProfileId, so no separate + // single-column index is needed for it.) + @@index([consulteeProfileId]) } model ConsulteeProfile { @@ -2522,17 +2752,16 @@ model SlotOfAvailabilityWeekly { endDay DayOfWeek endTimeUtc Int @db.SmallInt // Minutes since midnight UTC (0-1439) utcOffsetMinutes Int @default(0) @db.SmallInt // UTC offset in minutes at slot creation (e.g. 330 for IST, -300 for EST) - /// #503 — DST-proof source of truth: the consultant's LOCAL wall-clock - /// window plus the IANA zone. The frozen utcOffsetMinutes above breaks for - /// any DST zone (the offset at creation is wrong half the year). Write-side - /// populated now; the slot math migrates read-side in #503's follow-up, - /// then the offset column retires. Nullable until backfilled. - timezone String? @db.VarChar(64) + // #872 — DST schema finalized, implementation deferred. IST-only at launch, so + // the frozen utcOffsetMinutes above is the live source of truth. The columns + // below are the DST-correct representation (RFC 5545 / Calendly-style: local + // wall-clock + IANA zone, materialized to UTC per occurrence — a frozen offset + // drifts across DST). They are frozen into the launch schema now but left + // nullable + UNWRITTEN until the post-MVP algorithm + UI lands, so going + // DST-aware needs no post-launch migration. + timezone String? @db.VarChar(64) // IANA zone, e.g. "Asia/Kolkata" localStartMinutes Int? @db.SmallInt localEndMinutes Int? @db.SmallInt - /// Review catch on #843 — the offset can roll the LOCAL day across - /// midnight relative to the UTC startDay/endDay (IST 00:30 Mon local = - /// 19:00 Sun UTC), so local-day queries need their own columns. localStartDay DayOfWeek? localEndDay DayOfWeek? @@ -2557,6 +2786,8 @@ model SlotOfAvailabilityCustom { updatedAt DateTime @updatedAt @db.Timestamptz @@index([consultantProfileId]) + // CA-1 — availability-window range scans by consultant (#676). + @@index([consultantProfileId, startsAt, endsAt]) } //////////////////////////////////////////////////// PRICING PLANS //////////////////////////////////////////////////// @@ -2597,6 +2828,7 @@ model ConsultationPlan { @@index([organizationId]) @@index([visibility, organizationId]) + @@index([consultantProfileId]) } model Consultation { @@ -2657,9 +2889,14 @@ model SubscriptionPlan { learningOutcomes String[] @default([]) topics Topic[] @relation("TopicToSubscriptionPlan") - // Free trial fields - freeTrialEnabled Boolean @default(false) - freeTrialDurationMinutes Int @default(30) // 30 or 60 minutes + // Trial session offer. Free by default ONLY until paid-trial checkout is + // wired (booking rejects paid trials today); the wiring PR flips the + // default to 10000 (₹100) and removes that gate together. ₹0 stays + // allowed after the flip but the UI adds friction. Admin floor lives in + // PlatformPricingConfig.minTrialPriceInPaise. + trialEnabled Boolean @default(false) + trialDurationMinutes Int @default(30) // 30 or 60 minutes + trialPriceInPaise BigInt @default(0) // 0 = free trial consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) consultantProfileId String @@ -2681,12 +2918,16 @@ model SubscriptionPlan { @@index([organizationId]) @@index([visibility, organizationId]) + @@index([consultantProfileId]) } model Subscription { id String @id @default(cuid()) schedulingPeriodStartsAt DateTime @db.Timestamptz schedulingPeriodEndsAt DateTime @db.Timestamptz + // #676 AE-3 / #872 — ornamental at launch: allocation reads the consultant's + // user.timezone, never this. Kept as frozen schema; made load-bearing in #872 + // to pin a subscription's scheduling zone independent of the consultant's. schedulingTimezone String @default("Asia/Kolkata") status AppointmentStatus @default(PENDING) @map("requestStatus") @@ -2738,7 +2979,7 @@ enum AppointmentStatus { @@map("RequestStatus") } -// Free trial session tracking for subscriptions +// Trial session tracking for subscriptions model TrialSession { id String @id @default(cuid()) @@ -2759,17 +3000,27 @@ model TrialSession { appointment Appointment? @relation(fields: [appointmentId], references: [id]) appointmentId String? @unique + // Paid-trial pay-link (mirrors Consultation/Subscription). Column frozen + // now (schema gate); wiring needs createApprovalPaymentIntent to accept + // TRIAL — deliberate lib/payments change, tracked as a follow-up. + pendingPaymentUrl String? + + // Ledger truth once a paid trial settles (pendingPaymentUrl is only the + // checkout hand-off). Frozen with the wiring follow-up, same as above. + payment Payment? @relation("TrialSessionPayment", fields: [paymentId], references: [id], onUpdate: Cascade, onDelete: SetNull) + paymentId String? @unique + // Outcome tracking convertedToSubscription Subscription? @relation(fields: [convertedToSubscriptionId], references: [id]) convertedToSubscriptionId String? @unique // Enterprise (arch-4) — optional org tag. Set when the trial booker // is a LEARNER of an active org, so analytics can answer "how many - // trials did Wipro's members take, and how many converted?". Trials - // are free — no money moves, the org pays nothing — so this is pure - // *attribution*, not sponsorship. Full BookingUtilization integration - // (sub-trial-pool consumption, if we ever introduce paid trial pools) - // is deferred to Programs v2. + // trials did Wipro's members take, and how many converted?". The org + // pays nothing either way (a paid trial charges the consultee), so + // this is pure *attribution*, not sponsorship. Full BookingUtilization + // integration (sub-trial-pool consumption / org-sponsored trials) is + // deferred to Programs v2. organization Organization? @relation("TrialsByOrg", fields: [organizationId], references: [id], onDelete: SetNull) organizationId String? @@ -2981,6 +3232,8 @@ model Class { id String @id @default(cuid()) schedulingPeriodStartsAt DateTime? @db.Timestamptz schedulingPeriodEndsAt DateTime? @db.Timestamptz + // #676 AE-3 / #872 — ornamental at launch (allocation reads the consultant's + // user.timezone). Kept as frozen schema; made load-bearing in #872. schedulingTimezone String @default("Asia/Kolkata") status ClassStatus @default(SCHEDULED) waitlist Waitlist[] @@ -3119,6 +3372,7 @@ model Waitlist { @@unique([userId, webinarId]) @@unique([userId, classId]) @@index([userId]) + @@index([userId, status]) @@index([webinarId]) @@index([classId]) @@index([status]) @@ -3149,6 +3403,13 @@ model Appointment { appointmentType AppointmentsType slotsOfAppointment SlotOfAppointment[] + // Reserved dedupe surface for allocation retries (client double-submit, + // webhook redelivery): a batch will stamp its first appointment with the + // originating idempotency key so a replay trips @unique (P2002 → 409) instead + // of double-booking. Nullable by design — only real keys dedupe, NULLs don't + // collide. Column frozen now (schema gate); wiring tracked in #837. + allocationIdempotencyKey String? @unique + consultation Consultation? @relation(fields: [consultationId], references: [id], onUpdate: Cascade, onDelete: Cascade) consultationId String? @unique @@ -3163,13 +3424,20 @@ model Appointment { trialSession TrialSession? - payment Payment[] - documents AppointmentDocument[] + payment Payment[] + documents AppointmentDocument[] + // #appt-support — per-appointment support conversations + private CSAT. + supportThreads AppointmentSupportThread[] + supportFeedback AppointmentFeedback[] // #674 personal-vs-org scope split — set at checkout for org-context bookings. organization Organization? @relation("AppointmentByOrg", fields: [organizationId], references: [id], onDelete: SetNull) organizationId String? + // A10 — soft-delete tombstone (#676). Mirrors Payment.deletedAt; money rows + // Restrict their parents, so removal is a soft-delete, not a hard delete. + deletedAt DateTime? @db.Timestamptz + createdAt DateTime @default(now()) @db.Timestamptz updatedAt DateTime @updatedAt @db.Timestamptz @@ -3205,7 +3473,10 @@ model AppointmentDocument { reviewStatus DocumentReviewStatus @default(PENDING) reviewNotes String? reviewedAt DateTime? - reviewedBy String? // Consultant user ID + // A8 — FK-ified from a raw `reviewedBy String?` (#676). SetNull keeps the + // document if the reviewing user is deleted; the review history survives. + reviewedById String? + reviewedBy User? @relation("DocumentReviewer", fields: [reviewedById], references: [id], onDelete: SetNull) // Upload role - who uploaded this document uploadedByRole DocumentUploadRole @default(CONSULTEE) @@ -3219,6 +3490,11 @@ model AppointmentDocument { appointment Appointment @relation(fields: [appointmentId], references: [id], onUpdate: Cascade, onDelete: Cascade) appointmentId String + // DOC-3 (#694) — reconcile flags rows whose storage object is gone so the UI + // can badge them and they're held out of review until re-uploaded. + isStorageMissing Boolean @default(false) + missingDetectedAt DateTime? @db.Timestamptz + // Metadata uploadedAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -3227,6 +3503,8 @@ model AppointmentDocument { @@index([reviewStatus]) @@index([uploadedByRole]) @@index([responseToDocumentId]) + @@index([reviewedById]) + @@index([isStorageMissing]) } enum DocumentReviewStatus { @@ -3297,14 +3575,19 @@ model SlotOfAppointment { appointmentId String /// #440 — denormalized consultant for the DB-level overlap guard. The - /// exclusion constraint itself (btree_gist on (consultantProfileId, - /// tstzrange(startsAt, endsAt)) WHERE NOT isTentative) needs raw SQL and - /// lands with prisma-migrate adoption; the COLUMN is schema-freeze-gated - /// and checkout populates it from booking onward. Nullable for pre-#440 rows. + /// exclusion constraint (btree_gist on (consultantProfileId, + /// tstzrange(startsAt, endsAt)) WHERE NOT isTentative) is LIVE in the raw-SQL + /// sidecar (prisma/sql/check-constraints.sql: slot_no_confirmed_overlap); a + /// violation surfaces as Postgres 23P01 → 409 (SlotAllocationService + /// .classifyError). Nullable for attendee (webinar/class) slots, which the + /// partial-index guard excludes. consultantProfileId String? meetingSession MeetingSession? + // A10 — soft-delete tombstone (#676). Mirrors Appointment.deletedAt. + deletedAt DateTime? @db.Timestamptz + createdAt DateTime @default(now()) @db.Timestamptz updatedAt DateTime @updatedAt @db.Timestamptz @@ -3348,7 +3631,8 @@ model MeetingSession { endedAt DateTime? // When session actually ended endedReason String? // "call_ended", "session_timeout", "error" - recordings Recording[] + recordings Recording[] + attendances MeetingAttendance[] // STR-4 (#689) — per-participant join/leave audit slotOfAppointment SlotOfAppointment @relation(fields: [slotOfAppointmentId], references: [id], onUpdate: Cascade, onDelete: Cascade) slotOfAppointmentId String @unique @@ -3371,6 +3655,29 @@ model MeetingSession { @@index([organizationId, createdAt]) } +/// STR-4 (#689) — one row per (session, participant). Stream's +/// call.session_participant_joined/left webhooks upsert here: firstJoinedAt is +/// set on the first join, lastLeftAt advances on each leave, joinCount tracks +/// rejoins. Drives #471 (no-show = no row) and #472 (overrun = lastLeftAt/ +/// endedAt past the slot end). userId is the Stream user_id, which equals our +/// User.id — stored plain like MeetingSession.recordingStartedBy, no FK. +model MeetingAttendance { + id String @id @default(cuid()) + meetingSession MeetingSession @relation(fields: [meetingSessionId], references: [id], onUpdate: Cascade, onDelete: Cascade) + meetingSessionId String + userId String + firstJoinedAt DateTime @db.Timestamptz + lastLeftAt DateTime? @db.Timestamptz + joinCount Int @default(1) + + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz + + @@unique([meetingSessionId, userId]) + @@index([meetingSessionId]) + @@index([userId]) +} + // Recording storage types enum RecordingStorageType { @@ -3432,6 +3739,14 @@ model Recording { streamUrlExpiresAt DateTime? // When Stream S3 URL expires transferredAt DateTime? // When transferred to Supabase + // STR-2/3 (#689) — transfer-to-Supabase health. A failed transfer reverts + // status to READY and re-attempts next cron; these track repeated failures so + // the job can alert (transferFailureAlertedAt dedupes the page) before a + // STREAM_ONLY recording lapses at streamUrlExpiresAt. + transferAttempts Int @default(0) + lastTransferError String? + transferFailureAlertedAt DateTime? @db.Timestamptz + meetingSession MeetingSession @relation(fields: [meetingSessionId], references: [id], onUpdate: Cascade, onDelete: Cascade) meetingSessionId String @@ -3479,8 +3794,8 @@ model Payment { clientIdempotencyKey String? @unique // #781 §B — financial rows Restrict their parents; removal = soft-delete. // Hard delete remains possible only while no money row references this. - deletedAt DateTime? - expiresAt DateTime? // For tracking payment intent expiration + deletedAt DateTime? @db.Timestamptz + expiresAt DateTime? @db.Timestamptz // For tracking payment intent expiration isMockPayment Boolean @default(false) // For development: mock payments skip gateway calls // International payment tracking @@ -3529,6 +3844,7 @@ model Payment { // Back-relations for org settlement flows organizationEarnings OrganizationEarnings[] // PROVIDER/HYBRID 3-way split (one per org) organizationInvoiceSettled OrganizationInvoice? @relation("OrgInvoicePayment") // back-relation for OrganizationInvoice.payment + trialSessionPaid TrialSession? @relation("TrialSessionPayment") // back-relation for TrialSession.payment bookingUtilization BookingUtilization? @relation("PaymentBookingUtilization") // Program entitlement usage invoiceLineItems InvoiceLineItem[] // back-relation for InvoiceLineItem.payment @@ -3545,8 +3861,9 @@ model Payment { parentPayment Payment? @relation("OverageSideCharge", fields: [parentPaymentId], references: [id], onDelete: SetNull) childPayments Payment[] @relation("OverageSideCharge") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + // A6/A7/PM-16 — Timestamptz on the money models (#676). + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz overageEvents OverageEvent[] // Allow multiple users to pay for same appointment (webinars/classes) @@ -3559,9 +3876,12 @@ model Payment { @@index([appointmentId]) @@index([paymentStatus, createdAt]) @@index([organizationId]) + // Dashboard hot path: a user's payments scoped to an org, newest first. + @@index([userId, organizationId, createdAt]) @@index([billableToOrgInvoiceId]) @@index([parentPaymentId]) @@index([discountCodeId]) + @@index([billingAccountId]) } // Stackable funding leg. Each row captures one source contribution to a @@ -3630,8 +3950,7 @@ enum PaymentLegSource { enum PaymentGateway { STRIPE RAZORPAY - LEMON_SQUEEZY - XFLOW + DODO_PAYMENTS // post-MVP: evaluation pending CARD } @@ -3661,18 +3980,22 @@ model Refund { // cascade (legs/wallet/earnings/ledger/utilization/credit-note) has run, so the // app, gateway-webhook and backstop-cron paths each apply it exactly once. The // cron selects SUCCEEDED refunds where this is null. - cascadedAt DateTime? + cascadedAt DateTime? @db.Timestamptz /// #779 §A — gateway-failure capture for FAILED refunds. `reason` above is the /// OPERATOR's reason for refunding; `failureReason` is WHY the gateway rejected /// it. failedAt drives the "refund failed, action needed" notify; the reconcile /// cron selects FAILED refunds where failedNotifiedAt is null. failureReason String? @db.VarChar(500) - failedAt DateTime? - failedNotifiedAt DateTime? + failedAt DateTime? @db.Timestamptz + failedNotifiedAt DateTime? @db.Timestamptz - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + // A10 — soft-delete tombstone (#676). + deletedAt DateTime? @db.Timestamptz + + // A6/A7/PM-16 — Timestamptz on the money models (#676). + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz @@index([paymentId]) @@index([status]) @@ -3698,7 +4021,7 @@ model Dispute { disputeId String @unique // Gateway-specific dispute ID paymentGateway PaymentGateway evidence Json? // Evidence submitted to gateway - dueBy DateTime? // Deadline to respond to dispute + dueBy DateTime? @db.Timestamptz // Deadline to respond to dispute isChargeRefundable Boolean @default(true) payment Payment @relation(fields: [paymentId], references: [id], onUpdate: Cascade, onDelete: Restrict) @@ -3710,8 +4033,9 @@ model Dispute { assignedToUserId String? internalNotes String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + // A6/A7/PM-16 — Timestamptz on the money models (#676). + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz @@index([paymentId]) @@index([status]) @@ -3739,7 +4063,12 @@ enum EarningStatus { PENDING // In hold period HELD // Extended hold (dispute) READY // Ready for payout - PAID // Successfully paid + /// #837 E-03/E-04 — rolled into a payout batch but cash has NOT left yet + /// (batch exists / gateway not wired / ENABLE_LIVE_PAYOUTS off). Distinct + /// from PAID so finance exports + dashboards don't claim money moved before + /// the payout reaches COMPLETED with a UTR. Excluded from batch-eligibility. + BATCHED + PAID // Cash actually disbursed (payout COMPLETED + UTR) REFUNDED // Refunded to consultee /// Earnings accrued from a PENDING_VERIFICATION INVOICE-funded org /// before the org has been verified or paid its first invoice. The @@ -3795,14 +4124,15 @@ model ConsultantEarnings { // Status tracking status EarningStatus @default(PENDING) - holdUntil DateTime // Release after hold period - paidAt DateTime? + holdUntil DateTime @db.Timestamptz // Release after hold period + paidAt DateTime? @db.Timestamptz // Currency (always INR for MVP, extensible for multi-currency) currency Currency @default(INR) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + // A6/A7/PM-16 — Timestamptz on the money models (#676). + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Restrict) payment Payment @relation(fields: [paymentId], references: [id], onUpdate: Cascade, onDelete: Restrict) @@ -3839,22 +4169,29 @@ model ConsultantPayout { // MSME 43B(h) settlement deadline; mirrors OrganizationPayout. #776 — the // consultant is the supplier here, so the deadline derives from the // consultant's own msmeStatus/writtenAgreement, not the buyer org's. - mustPayByDate DateTime? + mustPayByDate DateTime? @db.Timestamptz // Processing failureReason String? retryCount Int @default(0) - processedAt DateTime? - approvedAt DateTime? + processedAt DateTime? @db.Timestamptz + approvedAt DateTime? @db.Timestamptz approvedBy String? + // UTR — Unique Transaction Reference; the bank/RBI settlement reference the + // gateway returns on a completed NEFT/IMPS/UPI payout. Mirrors + // OrganizationPayout.gatewayUtr; populates only after the + // payout.processed/transfer.paid webhook reconciles. + gatewayUtr String? + // Idempotency (required by Razorpay from March 2025). #778 §B — NOT NULL: // both payout creators stamp `payout__`; a transfer must never // submit without a dedupe key. idempotencyKey String @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + // A6/A7/PM-16 — Timestamptz on the money models (#676). + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Restrict) earnings ConsultantEarnings[] @@ -3949,6 +4286,12 @@ model TDSRecord { reportedInForm26Q Boolean @default(false) form26QFilingDate DateTime? + // PM-24 — TRACES filing artifacts (#676). Schema frozen now; population + // (challan recon, Form 16A cert + ack capture) deferred to #738. + challanNumber String? + certificateNumber String? + ackNumber String? + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Restrict) payout ConsultantPayout? @relation(fields: [payoutId], references: [id]) @@ -3958,6 +4301,7 @@ model TDSRecord { @@index([consultantProfileId, financialYear]) @@index([financialYear, quarter]) @@index([reportedInForm26Q]) + @@index([payoutId]) } // #778 §D / #784 — effective-dated TDS rate lookup, law-aware. The Income-tax @@ -4208,7 +4552,7 @@ model ReferralCode { totalReferrals Int @default(0) successfulReferrals Int @default(0) totalEarned BigInt @default(0) - maxReferrals Int @default(50) // Maximum referrals allowed per code + maxReferrals Int @default(25) // #880 — tightened from 50 to bound farming/liability isActive Boolean @default(true) user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) @@ -4294,6 +4638,32 @@ model ReferralCreditUsage { @@index([paymentId]) } +// #880 — single-row referral-program config (fixed id). Centralizes the +// conservative-launch controls: the program on/off switch, a monthly budget cap +// with auto-pause, and the ramped referrer reward. Paise stored as Int (well +// within range for a referral budget) to keep the gate arithmetic in number. +model ReferralProgramConfig { + id String @id @default("singleton") + isActive Boolean @default(true) + monthlyBudgetPaise BigInt? // null = unlimited + currentPeriod String @default("") // "YYYY-MM" of the active budget window + currentMonthSpentPaise BigInt @default(0) + referrerRewardPaise BigInt @default(30000) // ₹300 launch; ramp to 50000 (₹500) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Single-row platform pricing config (fixed id), mirroring +// ReferralProgramConfig. Admin/staff-editable floor under every plan's +// trialPriceInPaise: 0 keeps free trials allowed; raising it forces a +// minimum paid trial platform-wide without touching existing plans. +model PlatformPricingConfig { + id String @id @default("singleton") + minTrialPriceInPaise BigInt @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + enum ReferralStatus { SIGNED_UP QUALIFIED @@ -4347,6 +4717,7 @@ model Collaborator { @@index([webinarPlanId]) @@index([classPlanId]) @@index([status]) + @@index([invitedById]) } enum CollaboratorType { @@ -4548,6 +4919,11 @@ model ModerationAction { takenById String takenBy User @relation(fields: [takenById], references: [id], onUpdate: Cascade, onDelete: Cascade) + // Post-hoc record of which side-effects actually executed (sessions + // revoked, appointments cancelled, refund totals, per-step failures) — + // best-effort steps can partially fail, and staff needs to see what stuck. + sideEffects Json? + createdAt DateTime @default(now()) @@index([reportId]) @@ -4728,6 +5104,7 @@ model SsoProvider { // refuses. See audit Phase B.4. @@unique([providerId]) @@unique([organizationId, domain]) + @@index([userId]) @@map("ssoProvider") } @@ -4825,10 +5202,48 @@ enum DeliveryStatus { DEAD_LETTER } +/// #474 — transactional-email dead-letter. lib/email.ts persists the already +/// RENDERED message here when a Resend send fails (instead of swallowing it). +/// Storing the rendered fields (not the sender args) keeps retry a verbatim +/// re-send — no Json payload, no re-render dispatcher: the worker +/// (jobs/email/retry-failed-emails.ts) polls (status, nextRetryAt), re-sends the +/// stored html/text, walks the backoff schedule, marks DEAD_LETTER once +/// exhausted (operator-replayable). `emailType` is a plain tag for metrics only. +model FailedEmail { + id String @id @default(cuid()) + recipient String + fromAddress String? + replyTo String? + subject String + htmlBody String @db.Text + textBody String? @db.Text + emailType String + status EmailDeliveryStatus @default(PENDING) + attempts Int @default(0) + nextRetryAt DateTime? @db.Timestamptz + lastError String? @db.Text + sentAt DateTime? @db.Timestamptz + + createdAt DateTime @default(now()) @db.Timestamptz + updatedAt DateTime @updatedAt @db.Timestamptz + + @@index([status, nextRetryAt]) + @@index([status, updatedAt]) +} + +enum EmailDeliveryStatus { + PENDING + RETRY + SENT + /// All retries exhausted — terminal, operator-replayable. + DEAD_LETTER +} + /// Per-org SCIM 2.0 bearer tokens. Stored as SHA-256 hashes; the raw token /// is shown once at creation and never persisted. Auth path: bearer → /// sha256 → lookup in this table → load organizationId. Status flips to -/// REVOKED on explicit DELETE; tokens never expire unless revoked. +/// REVOKED on explicit DELETE; a token also stops authenticating once past +/// its optional expiresAt (enforced in lib/scim/auth.ts). model ScimToken { id String @id @default(cuid()) organizationId String @@ -4840,12 +5255,10 @@ model ScimToken { createdAt DateTime @default(now()) lastUsedAt DateTime? revokedAt DateTime? - /// Optional auto-rotation TTL. Null = "never expires"; non-null is - /// the absolute deadline after which `requireScimAuth` refuses the - /// token even if `status = ACTIVE`. The enforcement read is a - /// follow-up — the column exists today so OWNERs can set a 6/12 - /// month TTL when minting + the rotation reminder cron has a - /// stable column to scan. + /// Optional auto-rotation TTL. Null = "never expires"; non-null is the + /// absolute deadline after which the SCIM auth path (#789) refuses the token + /// with 401 even while `status = ACTIVE` — the row stays ACTIVE so the + /// operator sees it lapsed rather than revoked. expiresAt DateTime? @@index([organizationId, status]) @@ -4899,6 +5312,8 @@ model ErasureRequest { notes String? @db.Text @@index([status, requestedAt]) + @@index([processedByAdminId]) + @@index([userId]) } enum ErasureStatus { @@ -4996,6 +5411,11 @@ model OverageEvent { invoiceLineItemId String? invoiceLineItem InvoiceLineItem? @relation(fields: [invoiceLineItemId], references: [id], onDelete: SetNull) settledAt DateTime? + /// #715/#716 — set when a CHARGED overage is credited back because its parent + /// booking was fully refunded (CHARGE_MEMBER side-payment refunded / CHARGE_ORG + /// invoice netted by the refund credit note). Uncollected reversals reuse the + /// existing chargeStatus history; this stamp marks the post-collection case. + reversedAt DateTime? @db.Timestamptz /// #779 §A — CHARGE_MEMBER timeout telemetry. A member-pays overage sits /// PENDING until the side-payment SUCCEEDS; if abandoned it must time out → @@ -5012,6 +5432,8 @@ model OverageEvent { // #781 §D — per-assignment settlement/reconcile scans @@index([programAssignmentId, chargeStatus, createdAt]) @@index([chargeStatus, lastChargeAttemptAt]) + @@index([invoiceLineItemId]) + @@index([paymentId]) } /// #769 Comment 4 — multi-jurisdiction tax prep. Today every org is IN; diff --git a/backend/pubspec.yaml b/backend/pubspec.yaml index 3c1d5e8..3bb194f 100644 --- a/backend/pubspec.yaml +++ b/backend/pubspec.yaml @@ -18,7 +18,8 @@ dependencies: json_annotation: ^4.9.0 logging: ^1.3.0 postgres: ^3.4.5 - prisma_flutter_connector: ^0.6.0 + pointycastle: ^3.9.1 + prisma_flutter_connector: ^0.9.0 uuid: ^4.5.1 dev_dependencies: diff --git a/backend/routes/_middleware.dart b/backend/routes/_middleware.dart index 7dbde37..ecd288d 100644 --- a/backend/routes/_middleware.dart +++ b/backend/routes/_middleware.dart @@ -30,8 +30,7 @@ Handler middleware(Handler handler) { } /// Pattern to match any localhost or 127.0.0.1 origin (any port) -final _localhostPattern = - RegExp(r'^http://(localhost|127\.0\.0\.1)(:\d+)?$'); +final _localhostPattern = RegExp(r'^http://(localhost|127\.0\.0\.1)(:\d+)?$'); /// Get CORS headers based on environment /// In production, restricts origins; in development, allows any localhost @@ -40,14 +39,14 @@ Map _getCorsHeaders(String? requestOrigin) { String allowedOrigin; if (isProduction) { - allowedOrigin = Platform.environment['ALLOWED_ORIGINS'] ?? - 'https://familiarise.com'; + allowedOrigin = + Platform.environment['ALLOWED_ORIGINS'] ?? 'https://familiarise.com'; } else { // Reflect the request origin if it's any localhost variant - allowedOrigin = (requestOrigin != null && - _localhostPattern.hasMatch(requestOrigin)) - ? requestOrigin - : 'http://localhost:3000'; + allowedOrigin = + (requestOrigin != null && _localhostPattern.hasMatch(requestOrigin)) + ? requestOrigin + : 'http://localhost:3000'; } return { diff --git a/backend/routes/api/announcements/index.dart b/backend/routes/api/announcements/index.dart index c205890..ca0fad3 100644 --- a/backend/routes/api/announcements/index.dart +++ b/backend/routes/api/announcements/index.dart @@ -17,7 +17,9 @@ Future onRequest(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -38,7 +40,9 @@ Future onRequest(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to load announcements'}}, + body: { + 'error': {'message': 'Failed to load announcements'} + }, ); } } diff --git a/backend/routes/api/appointments/[id]/cancel.dart b/backend/routes/api/appointments/[id]/cancel.dart index df7c920..3a17347 100644 --- a/backend/routes/api/appointments/[id]/cancel.dart +++ b/backend/routes/api/appointments/[id]/cancel.dart @@ -92,6 +92,14 @@ Future onRequest(RequestContext context, String id) async { 'error': {'message': 'Invalid request body format'}, }, ); + } on ArgumentError catch (e) { + // Unsupported cancellation reason -> validation error, not a 500. + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.error( 'Error in POST /api/appointments/$id/cancel', diff --git a/backend/routes/api/appointments/[id]/documents/[docId]/index.dart b/backend/routes/api/appointments/[id]/documents/[docId]/index.dart index 1c0166b..d0e384b 100644 --- a/backend/routes/api/appointments/[id]/documents/[docId]/index.dart +++ b/backend/routes/api/appointments/[id]/documents/[docId]/index.dart @@ -4,7 +4,6 @@ import 'package:backend/database/database_client.dart'; import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/appointments/:id/documents/:docId — Details /// PUT /api/appointments/:id/documents/:docId — Review @@ -37,41 +36,37 @@ Future _handle( if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } // Verify user is a participant in the appointment final db = context.read(); - final apptQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findFirst) - .where({'id': appointmentId}) - .build(); - final appointment = await db.executor.executeQueryAsSingleMap( - apptQuery, + final appointmentRecord = await db.prisma.appointment.findUnique( + where: AppointmentWhereUniqueInput(id: appointmentId), ); - if (appointment == null) { + if (appointmentRecord == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Appointment not found'}}, + body: { + 'error': {'message': 'Appointment not found'} + }, ); } + final appointment = appointmentRecord.toJson(); final user = await db.users.findById(userId); - final consulteeProfileId = - user?['consulteeProfileId'] as String?; - final consultantProfileId = - user?['consultantProfileId'] as String?; - final apptConsulteeId = - appointment['consulteeProfileId'] as String?; - final apptConsultantId = - appointment['consultantProfileId'] as String?; - - final isConsultee = consulteeProfileId != null && - consulteeProfileId == apptConsulteeId; - final isConsultant = consultantProfileId != null && - consultantProfileId == apptConsultantId; + final consulteeProfileId = user?['consulteeProfileId'] as String?; + final consultantProfileId = user?['consultantProfileId'] as String?; + final apptConsulteeId = appointment['consulteeProfileId'] as String?; + final apptConsultantId = appointment['consultantProfileId'] as String?; + + final isConsultee = + consulteeProfileId != null && consulteeProfileId == apptConsulteeId; + final isConsultant = + consultantProfileId != null && consultantProfileId == apptConsultantId; if (!isConsultee && !isConsultant) { return Response.json( @@ -99,7 +94,9 @@ Future _handle( ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Operation failed'}}, + body: { + 'error': {'message': 'Operation failed'} + }, ); } } @@ -109,7 +106,9 @@ Future _handleGet(DatabaseClient db, String docId) async { if (doc == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Document not found'}}, + body: { + 'error': {'message': 'Document not found'} + }, ); } return Response.json(body: {'data': doc.toJson()}); @@ -128,17 +127,18 @@ Future _handlePut( if (reviewStatusStr == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'reviewStatus is required'}}, + body: { + 'error': {'message': 'reviewStatus is required'} + }, ); } // Validate reviewStatus — return 400 for invalid values - final reviewStatus = DocumentReviewStatus.values - .cast() - .firstWhere( - (s) => s!.name.toUpperCase() == reviewStatusStr.toUpperCase(), - orElse: () => null, - ); + final reviewStatus = + DocumentReviewStatus.values.cast().firstWhere( + (s) => s!.name.toUpperCase() == reviewStatusStr.toUpperCase(), + orElse: () => null, + ); if (reviewStatus == null) { return Response.json( diff --git a/backend/routes/api/appointments/[id]/documents/index.dart b/backend/routes/api/appointments/[id]/documents/index.dart index 122ae5f..b475857 100644 --- a/backend/routes/api/appointments/[id]/documents/index.dart +++ b/backend/routes/api/appointments/[id]/documents/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/appointments/:id/documents — List documents /// POST /api/appointments/:id/documents — Upload a document @@ -26,18 +25,15 @@ Future _authorizeParticipant( String userId, ) async { final db = context.read(); - final apptQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findFirst) - .where({'id': appointmentId}) - .build(); - final appointment = await db.executor.executeQueryAsSingleMap( - apptQuery, + final appointment = await db.prisma.appointment.findUnique( + where: AppointmentWhereUniqueInput(id: appointmentId), ); if (appointment == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Appointment not found'}}, + body: { + 'error': {'message': 'Appointment not found'} + }, ); } @@ -47,38 +43,29 @@ Future _authorizeParticipant( // Appointment links to Consultation (which has requestedById = consulteeProfileId) // and to ConsultationPlan (which has consultantProfileId). - final consultationId = appointment['consultationId'] as String?; + final consultationId = appointment.consultationId; String? apptConsulteeId; String? apptConsultantId; if (consultationId != null) { - final consultQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findFirst) - .where({'id': consultationId}) - .build(); - final consultation = - await db.executor.executeQueryAsSingleMap(consultQuery); - apptConsulteeId = consultation?['requestedById'] as String?; - - final planId = consultation?['consultationPlanId'] as String?; + final consultation = await db.prisma.consultation.findFirst( + where: ConsultationWhereInput(id: StringFilter(equals: consultationId)), + ); + apptConsulteeId = consultation?.requestedById; + + final planId = consultation?.consultationPlanId; if (planId != null) { - final planQuery = JsonQueryBuilder() - .model('ConsultationPlan') - .action(QueryAction.findFirst) - .where({'id': planId}) - .build(); - final plan = await db.executor.executeQueryAsSingleMap(planQuery); - apptConsultantId = plan?['consultantProfileId'] as String?; + final plan = await db.prisma.consultationPlan.findFirst( + where: ConsultationPlanWhereInput(id: StringFilter(equals: planId)), + ); + apptConsultantId = plan?.consultantProfileId; } } - if (consulteeProfileId != null && - consulteeProfileId == apptConsulteeId) { + if (consulteeProfileId != null && consulteeProfileId == apptConsulteeId) { return 'CONSULTEE'; } - if (consultantProfileId != null && - consultantProfileId == apptConsultantId) { + if (consultantProfileId != null && consultantProfileId == apptConsultantId) { return 'CONSULTANT'; } @@ -98,7 +85,9 @@ Future _handleGet(RequestContext context, String id) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -120,7 +109,9 @@ Future _handleGet(RequestContext context, String id) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to list documents'}}, + body: { + 'error': {'message': 'Failed to list documents'} + }, ); } } @@ -131,7 +122,9 @@ Future _handlePost(RequestContext context, String id) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -176,8 +169,7 @@ Future _handlePost(RequestContext context, String id) async { storagePath: storagePath, description: body['description'] as String?, uploadedByRole: uploadedByRole, - responseToDocumentId: - body['responseToDocumentId'] as String?, + responseToDocumentId: body['responseToDocumentId'] as String?, ); return Response.json( @@ -193,7 +185,9 @@ Future _handlePost(RequestContext context, String id) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to upload document'}}, + body: { + 'error': {'message': 'Failed to upload document'} + }, ); } } diff --git a/backend/routes/api/auth/change-password.dart b/backend/routes/api/auth/change-password.dart index 2ffe8e6..291a9cf 100644 --- a/backend/routes/api/auth/change-password.dart +++ b/backend/routes/api/auth/change-password.dart @@ -32,8 +32,7 @@ Future onRequest(RequestContext context) async { ); } - final body = - await context.request.json() as Map; + final body = await context.request.json() as Map; final currentPassword = body['currentPassword'] as String?; final newPassword = body['newPassword'] as String?; @@ -42,8 +41,7 @@ Future onRequest(RequestContext context) async { statusCode: HttpStatus.badRequest, body: { 'error': { - 'message': - 'currentPassword and newPassword are required', + 'message': 'currentPassword and newPassword are required', }, }, ); diff --git a/backend/routes/api/auth/forgot-password.dart b/backend/routes/api/auth/forgot-password.dart index 532504b..91e85b7 100644 --- a/backend/routes/api/auth/forgot-password.dart +++ b/backend/routes/api/auth/forgot-password.dart @@ -19,8 +19,7 @@ Future onRequest(RequestContext context) async { } try { - final body = - await context.request.json() as Map; + final body = await context.request.json() as Map; final email = body['email'] as String?; if (email == null) { @@ -38,8 +37,7 @@ Future onRequest(RequestContext context) async { // Always return 200 to prevent email enumeration return Response.json( body: { - 'message': - 'If an account exists, a reset link has been sent', + 'message': 'If an account exists, a reset link has been sent', }, ); } catch (e, stackTrace) { @@ -52,8 +50,7 @@ Future onRequest(RequestContext context) async { // Still return 200 to prevent email enumeration return Response.json( body: { - 'message': - 'If an account exists, a reset link has been sent', + 'message': 'If an account exists, a reset link has been sent', }, ); } diff --git a/backend/routes/api/auth/reset-password.dart b/backend/routes/api/auth/reset-password.dart index e1f6eb0..fe31a1b 100644 --- a/backend/routes/api/auth/reset-password.dart +++ b/backend/routes/api/auth/reset-password.dart @@ -21,8 +21,7 @@ Future onRequest(RequestContext context) async { } try { - final body = - await context.request.json() as Map; + final body = await context.request.json() as Map; final token = body['token'] as String?; final newPassword = body['newPassword'] as String?; diff --git a/backend/routes/api/auth/revoke-other-sessions.dart b/backend/routes/api/auth/revoke-other-sessions.dart index f2d6d08..8492bc1 100644 --- a/backend/routes/api/auth/revoke-other-sessions.dart +++ b/backend/routes/api/auth/revoke-other-sessions.dart @@ -28,10 +28,8 @@ Future onRequest(RequestContext context) async { } // Extract current session ID from the JWT payload - final authHeader = - context.request.headers['authorization']; - if (authHeader == null || - !authHeader.startsWith('Bearer ')) { + final authHeader = context.request.headers['authorization']; + if (authHeader == null || !authHeader.startsWith('Bearer ')) { return Response.json( statusCode: HttpStatus.unauthorized, body: { @@ -41,8 +39,7 @@ Future onRequest(RequestContext context) async { } final token = authHeader.substring(7); - final payload = - context.read().tryVerify(token); + final payload = context.read().tryVerify(token); final sessionId = payload?['sessionId'] as String?; if (sessionId == null) { diff --git a/backend/routes/api/auth/revoke-session.dart b/backend/routes/api/auth/revoke-session.dart index 57a0363..f19b5b7 100644 --- a/backend/routes/api/auth/revoke-session.dart +++ b/backend/routes/api/auth/revoke-session.dart @@ -30,8 +30,7 @@ Future onRequest(RequestContext context) async { ); } - final body = - await context.request.json() as Map; + final body = await context.request.json() as Map; final sessionId = body['sessionId'] as String?; if (sessionId == null) { diff --git a/backend/routes/api/auth/set-password.dart b/backend/routes/api/auth/set-password.dart index d43e51a..685146e 100644 --- a/backend/routes/api/auth/set-password.dart +++ b/backend/routes/api/auth/set-password.dart @@ -31,8 +31,7 @@ Future onRequest(RequestContext context) async { ); } - final body = - await context.request.json() as Map; + final body = await context.request.json() as Map; final newPassword = body['newPassword'] as String?; if (newPassword == null) { diff --git a/backend/routes/api/auth/verify-email.dart b/backend/routes/api/auth/verify-email.dart index 6d0cb33..6fa46ca 100644 --- a/backend/routes/api/auth/verify-email.dart +++ b/backend/routes/api/auth/verify-email.dart @@ -68,8 +68,7 @@ Future _handlePost(RequestContext context) async { /// GET — confirm email verification with token Future _handleGet(RequestContext context) async { try { - final token = - context.request.uri.queryParameters['token']; + final token = context.request.uri.queryParameters['token']; if (token == null || token.isEmpty) { return Response.json( diff --git a/backend/routes/api/checkout/index.dart b/backend/routes/api/checkout/index.dart index 86cd4df..c2f7eb8 100644 --- a/backend/routes/api/checkout/index.dart +++ b/backend/routes/api/checkout/index.dart @@ -9,7 +9,6 @@ import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; import 'package:dotenv/dotenv.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// Checkout endpoints /// @@ -105,6 +104,39 @@ Future _handleCreateCheckout(RequestContext context) async { ); } + // Validate the two enum-shaped inputs up front. Both drive control flow + // (consultation vs subscription) and are passed through to createPayment, + // so an unsupported value must be a 400 here rather than an exception from + // some later repository call. + const allowedTypes = {'CONSULTATION', 'SUBSCRIPTION'}; + final normalizedType = appointmentType.trim().toUpperCase(); + if (!allowedTypes.contains(normalizedType)) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': { + 'message': 'Unsupported appointmentType: $appointmentType', + 'allowed': allowedTypes.toList(), + }, + }, + ); + } + + final normalizedGateway = paymentGateway.trim().toUpperCase(); + final allowedGateways = + PaymentGateway.values.map((g) => g.toJson()).toList(); + if (!allowedGateways.contains(normalizedGateway)) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': { + 'message': 'Unsupported paymentGateway: $paymentGateway', + 'allowed': allowedGateways, + }, + }, + ); + } + final db = context.read(); // Determine if this is request-then-pay or direct checkout @@ -119,7 +151,7 @@ Future _handleCreateCheckout(RequestContext context) async { if (bookingId != null) { // Request-then-pay flow: Use existing booking - booking = await db.checkout.getBookingById(bookingId, appointmentType); + booking = await db.checkout.getBookingById(bookingId, normalizedType); if (booking == null) { return Response.json( statusCode: HttpStatus.notFound, @@ -134,7 +166,7 @@ Future _handleCreateCheckout(RequestContext context) async { } // Get plan from booking - if (appointmentType.toUpperCase() == 'CONSULTATION') { + if (normalizedType == 'CONSULTATION') { plan = booking['consultationPlan'] as Map?; } else { plan = booking['subscriptionPlan'] as Map?; @@ -166,7 +198,7 @@ Future _handleCreateCheckout(RequestContext context) async { final requestedById = consulteeProfile['id'] as String; // Get plan details - if (appointmentType.toUpperCase() == 'CONSULTATION') { + if (normalizedType == 'CONSULTATION') { plan = await db.checkout.getConsultationPlan(planId); if (plan == null) { @@ -343,17 +375,14 @@ Future _handleCreateCheckout(RequestContext context) async { // Get appointment ID for the booking (if consultation) String? appointmentId; - if (appointmentType.toUpperCase() == 'CONSULTATION') { + if (normalizedType == 'CONSULTATION') { // Appointment was created with the booking - fetch it - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findFirst) - .where({'consultationId': finalBookingId}).build(); - final appointmentResult = - await db.executor.executeQueryAsSingleMap(appointmentQuery); - if (appointmentResult != null) { - appointmentId = appointmentResult['id'] as String?; - } + final appointmentResult = await db.prisma.appointment.findFirst( + where: AppointmentWhereInput( + consultationId: StringFilter(equals: finalBookingId), + ), + ); + appointmentId = appointmentResult?.id; } // Create payment record @@ -362,7 +391,7 @@ Future _handleCreateCheckout(RequestContext context) async { amount: amountInSmallestUnit, originalAmount: originalAmountInSmallestUnit, currency: currency, - paymentGateway: paymentGateway.toUpperCase(), + paymentGateway: normalizedGateway, appointmentId: appointmentId, discountCodeId: discountCodeId, description: 'Booking with ${consultantName ?? 'consultant'}', @@ -488,14 +517,11 @@ Future _handleCreateCheckout(RequestContext context) async { // Update payment record with Stripe payment intent ID // We need to update the paymentIntent field to store the Stripe pi_ ID - final updateQuery = JsonQueryBuilder() - .model('Payment') - .action(QueryAction.update) - .where({'id': paymentIdStr}).data({ - 'paymentIntent': paymentIntent.id, - 'updatedAt': DateTime.now().toUtc().toIso8601String(), - }).build(); - await db.executor.executeMutation(updateQuery); + // (typed update auto-refreshes updatedAt) + await db.prisma.payment.update( + where: PaymentWhereUniqueInput(id: paymentIdStr), + data: UpdatePaymentInput(paymentIntent: paymentIntent.id), + ); SentryLogger.info( 'Created Stripe PaymentIntent: ${paymentIntent.id} ' @@ -538,7 +564,7 @@ Future _handleCreateCheckout(RequestContext context) async { if (discountAmount != null) 'discountAmount': discountAmount / 100, if (discountCode != null) 'discountCode': discountCode, 'bookingId': finalBookingId, - 'bookingType': appointmentType.toUpperCase(), + 'bookingType': normalizedType, }), ); } on FormatException catch (_) { @@ -548,6 +574,13 @@ Future _handleCreateCheckout(RequestContext context) async { 'error': {'message': 'Invalid request body format'}, }, ); + } on ArgumentError catch (e) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.error( 'Error in POST /api/checkout', diff --git a/backend/routes/api/checkout/validate-discount.dart b/backend/routes/api/checkout/validate-discount.dart index 41ae10c..65b0188 100644 --- a/backend/routes/api/checkout/validate-discount.dart +++ b/backend/routes/api/checkout/validate-discount.dart @@ -77,8 +77,7 @@ Future onRequest(RequestContext context) async { final discountType = discount['discountType'] as String?; final discountValue = (discount['discountValue'] as num?)?.toDouble(); final discountAmount = (discount['discountAmount'] as num?)?.toDouble(); - final maximumDiscountAmount = - (discount['maxDiscount'] as num?)?.toDouble(); + final maximumDiscountAmount = (discount['maxDiscount'] as num?)?.toDouble(); final expiresAt = discount['expiresAt']; return Response.json( diff --git a/backend/routes/api/checkout/verify.dart b/backend/routes/api/checkout/verify.dart index 8e0de7b..3cc734d 100644 --- a/backend/routes/api/checkout/verify.dart +++ b/backend/routes/api/checkout/verify.dart @@ -7,7 +7,6 @@ import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; import 'package:dotenv/dotenv.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// Checkout verification endpoint /// @@ -72,13 +71,11 @@ Future _handleVerifyPayment(RequestContext context) async { // BUG FIX: Frontend sends payment UUID as payment_intent param. // Look up by primary key `id`, not by `paymentIntent` field. - final paymentQuery = JsonQueryBuilder() - .model('Payment') - .action(QueryAction.findUnique) - .where({'id': paymentIntent}).build(); - final payment = await db.executor.executeQueryAsSingleMap(paymentQuery); + final paymentRecord = await db.prisma.payment.findUnique( + where: PaymentWhereUniqueInput(id: paymentIntent), + ); - if (payment == null) { + if (paymentRecord == null) { return Response.json( statusCode: io.HttpStatus.notFound, body: { @@ -89,6 +86,8 @@ Future _handleVerifyPayment(RequestContext context) async { ); } + final payment = paymentRecord.toJson(); + // Verify the payment belongs to the authenticated user final paymentUserId = payment['userId'] as String?; if (paymentUserId != userId) { @@ -204,16 +203,13 @@ Future _handleVerifyPayment(RequestContext context) async { // Update booking status based on type if (appointmentId != null) { // Get appointment to find booking - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findUnique) - .where({'id': appointmentId}).build(); - final appointment = - await db.executor.executeQueryAsSingleMap(appointmentQuery); + final appointment = await db.prisma.appointment.findUnique( + where: AppointmentWhereUniqueInput(id: appointmentId), + ); if (appointment != null) { - final consultationId = appointment['consultationId'] as String?; - final subscriptionId = appointment['subscriptionId'] as String?; + final consultationId = appointment.consultationId; + final subscriptionId = appointment.subscriptionId; if (consultationId != null) { // Update consultation status to SCHEDULED @@ -274,16 +270,13 @@ Future _buildVerificationResponse( if (appointmentId != null) { // Get appointment details - final appointmentQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findUnique) - .where({'id': appointmentId}).build(); - final appointment = - await db.executor.executeQueryAsSingleMap(appointmentQuery); + final appointment = await db.prisma.appointment.findUnique( + where: AppointmentWhereUniqueInput(id: appointmentId), + ); if (appointment != null) { - final consultationId = appointment['consultationId'] as String?; - final subscriptionId = appointment['subscriptionId'] as String?; + final consultationId = appointment.consultationId; + final subscriptionId = appointment.subscriptionId; if (consultationId != null) { bookingType = 'CONSULTATION'; @@ -305,14 +298,14 @@ Future _buildVerificationResponse( } // Get scheduled slot - final slotsQuery = JsonQueryBuilder() - .model('SlotOfAppointment') - .action(QueryAction.findFirst) - .where({'appointmentId': appointmentId}).orderBy( - {'startsAt': 'asc'}).build(); - final slot = await db.executor.executeQueryAsSingleMap(slotsQuery); + final slot = await db.prisma.slotOfAppointment.findFirst( + where: SlotOfAppointmentWhereInput( + appointmentId: StringFilter(equals: appointmentId), + ), + orderBy: const SlotOfAppointmentOrderByInput(startsAt: SortOrder.asc), + ); if (slot != null) { - scheduledAt = slot['startsAt']?.toString(); + scheduledAt = slot.startsAt.toUtc().toIso8601String(); } } else if (subscriptionId != null) { bookingType = 'SUBSCRIPTION'; @@ -345,8 +338,8 @@ Future _buildVerificationResponse( : (pendingMessage != null ? 'PENDING' : 'FAILED'), if (appointmentId != null) 'appointmentId': appointmentId, if (bookingType != null) 'bookingType': bookingType, - 'message': pendingMessage ?? - (success ? 'Payment successful' : 'Payment failed'), + 'message': + pendingMessage ?? (success ? 'Payment successful' : 'Payment failed'), if (consultantName != null) 'consultantName': consultantName, if (planTitle != null) 'planTitle': planTitle, if (scheduledAt != null) 'scheduledAt': scheduledAt, diff --git a/backend/routes/api/collaborations/[id]/index.dart b/backend/routes/api/collaborations/[id]/index.dart index 6d46ac3..1445e71 100644 --- a/backend/routes/api/collaborations/[id]/index.dart +++ b/backend/routes/api/collaborations/[id]/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/collaborations/:id — Collaboration details with revenue split /// PUT /api/collaborations/:id — Update collaboration (revenue split) @@ -29,8 +28,7 @@ Future _findAndAuthorize( // Get user's consultant profile ID final user = await db.users.findById(userId); - final consultantProfileId = - user?['consultantProfileId'] as String?; + final consultantProfileId = user?['consultantProfileId'] as String?; if (consultantProfileId == null) { return Response.json( statusCode: HttpStatus.forbidden, @@ -42,42 +40,32 @@ Future _findAndAuthorize( ); } - // Try webinar collaborator - final webQuery = JsonQueryBuilder() - .model('WebinarCollaborator') - .action(QueryAction.findFirst) - .where({'id': id}) - .build(); - var collab = await db.executor.executeQueryAsSingleMap(webQuery); - - // Try class collaborator if not found - if (collab == null) { - final classQuery = JsonQueryBuilder() - .model('ClassCollaborator') - .action(QueryAction.findFirst) - .where({'id': id}) - .build(); - collab = await db.executor.executeQueryAsSingleMap(classQuery); - } + // WebinarCollaborator + ClassCollaborator were consolidated into a single + // Collaborator model (collaboratorType discriminates webinar vs class). + final collab = await db.prisma.collaborator.findFirst( + where: CollaboratorWhereInput(id: StringFilter(equals: id)), + ); if (collab == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Collaboration not found'}}, + body: { + 'error': {'message': 'Collaboration not found'} + }, ); } // Verify the user is a participant in this collaboration - final collabProfileId = - collab['consultantProfileId'] as String?; - if (collabProfileId != consultantProfileId) { + if (collab.consultantProfileId != consultantProfileId) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Collaboration not found'}}, + body: { + 'error': {'message': 'Collaboration not found'} + }, ); } - return collab; + return collab.toJson(); } Future _handleGet(RequestContext context, String id) async { @@ -86,7 +74,9 @@ Future _handleGet(RequestContext context, String id) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -109,7 +99,9 @@ Future _handleGet(RequestContext context, String id) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to get collaboration'}}, + body: { + 'error': {'message': 'Failed to get collaboration'} + }, ); } } @@ -120,42 +112,35 @@ Future _handlePut(RequestContext context, String id) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } // Authorize first final authResult = await _findAndAuthorize(context, id, userId); if (authResult is Response) return authResult; - final collab = authResult as Map; final body = await context.request.json() as Map; final revenueSplit = body['revenueSharePercentage'] as num?; final db = context.read(); - final now = DateTime.now().toUtc().toIso8601String(); - - // Determine which model to update based on what we found - final modelName = collab.containsKey('webinarPlanId') - ? 'WebinarCollaborator' - : 'ClassCollaborator'; - - final updateQuery = JsonQueryBuilder() - .model(modelName) - .action(QueryAction.update) - .where({'id': id}) - .data({ - if (revenueSplit != null) - 'revenueSharePercentage': revenueSplit.toDouble(), - 'updatedAt': now, - }).build(); - - final updated = - await db.executor.executeQueryAsSingleMap(updateQuery); + + // Single Collaborator model; revenueSharePercentage is now stored as + // basis points (revenueShareBps, e.g. 30% -> 3000) for integer money math. + // Typed update auto-refreshes updatedAt — no manual timestamp needed. + final updated = await db.prisma.collaborator.update( + where: CollaboratorWhereUniqueInput(id: id), + data: UpdateCollaboratorInput( + revenueShareBps: revenueSplit != null + ? (revenueSplit.toDouble() * 100).round() + : null, + ), + ); return Response.json( body: { - 'data': - updated != null ? serializeForJson(updated) : null, + 'data': serializeForJson(updated.toJson()), }, ); } catch (e, stackTrace) { @@ -167,7 +152,9 @@ Future _handlePut(RequestContext context, String id) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to update collaboration'}}, + body: { + 'error': {'message': 'Failed to update collaboration'} + }, ); } } diff --git a/backend/routes/api/collaborations/[id]/respond.dart b/backend/routes/api/collaborations/[id]/respond.dart index 30b9648..966867d 100644 --- a/backend/routes/api/collaborations/[id]/respond.dart +++ b/backend/routes/api/collaborations/[id]/respond.dart @@ -32,8 +32,7 @@ Future onRequest(RequestContext context, String id) async { final response = body['response'] as String?; final planType = body['planType'] as String?; - if (response == null || - !['ACCEPTED', 'DECLINED'].contains(response)) { + if (response == null || !['ACCEPTED', 'DECLINED'].contains(response)) { return Response.json( statusCode: HttpStatus.badRequest, body: { diff --git a/backend/routes/api/consultant/payout-accounts/index.dart b/backend/routes/api/consultant/payout-accounts/index.dart index e3f438e..4570c5a 100644 --- a/backend/routes/api/consultant/payout-accounts/index.dart +++ b/backend/routes/api/consultant/payout-accounts/index.dart @@ -23,14 +23,15 @@ Future _handleGet(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); final user = await db.users.findById(userId); - final consultantProfileId = - user?['consultantProfileId'] as String?; + final consultantProfileId = user?['consultantProfileId'] as String?; if (consultantProfileId == null) { return Response.json( statusCode: HttpStatus.badRequest, @@ -46,6 +47,13 @@ Future _handleGet(RequestContext context) async { return Response.json( body: {'data': accounts.map(serializeForJson).toList()}, ); + } on ArgumentError catch (e) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.severe( 'Payout accounts list failed', @@ -55,7 +63,9 @@ Future _handleGet(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to list payout accounts'}}, + body: { + 'error': {'message': 'Failed to list payout accounts'} + }, ); } } @@ -66,14 +76,15 @@ Future _handlePost(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); final user = await db.users.findById(userId); - final consultantProfileId = - user?['consultantProfileId'] as String?; + final consultantProfileId = user?['consultantProfileId'] as String?; if (consultantProfileId == null) { return Response.json( statusCode: HttpStatus.badRequest, @@ -85,8 +96,7 @@ Future _handlePost(RequestContext context) async { final body = await context.request.json() as Map; final provider = body['provider'] as String? ?? 'RAZORPAY'; - final accountType = - body['accountType'] as String? ?? 'BANK_ACCOUNT'; + final accountType = body['accountType'] as String? ?? 'BANK_ACCOUNT'; final account = await db.payoutAccounts.create( consultantProfileId: consultantProfileId, @@ -103,6 +113,13 @@ Future _handlePost(RequestContext context) async { statusCode: HttpStatus.created, body: {'data': serializeForJson(account)}, ); + } on ArgumentError catch (e) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.severe( 'Payout account creation failed', @@ -112,7 +129,9 @@ Future _handlePost(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to create payout account'}}, + body: { + 'error': {'message': 'Failed to create payout account'} + }, ); } } diff --git a/backend/routes/api/consultant/profile.dart b/backend/routes/api/consultant/profile.dart index d9af127..0dad85c 100644 --- a/backend/routes/api/consultant/profile.dart +++ b/backend/routes/api/consultant/profile.dart @@ -25,7 +25,9 @@ Future _handleGet(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -35,7 +37,9 @@ Future _handleGet(RequestContext context) async { if (profile == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Consultant profile not found'}}, + body: { + 'error': {'message': 'Consultant profile not found'} + }, ); } @@ -50,7 +54,9 @@ Future _handleGet(RequestContext context) async { return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to fetch consultant profile'}}, + body: { + 'error': {'message': 'Failed to fetch consultant profile'} + }, ); } } @@ -80,7 +86,9 @@ Future _handlePatch(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -91,7 +99,9 @@ Future _handlePatch(RequestContext context) async { if (existing == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Consultant profile not found'}}, + body: { + 'error': {'message': 'Consultant profile not found'} + }, ); } @@ -145,7 +155,9 @@ Future _handlePatch(RequestContext context) async { return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to update consultant profile'}}, + body: { + 'error': {'message': 'Failed to update consultant profile'} + }, ); } } diff --git a/backend/routes/api/consultant/tax-info/index.dart b/backend/routes/api/consultant/tax-info/index.dart index 4f5978c..a47563e 100644 --- a/backend/routes/api/consultant/tax-info/index.dart +++ b/backend/routes/api/consultant/tax-info/index.dart @@ -1,13 +1,13 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:io' as io show Platform; import 'package:backend/database/database_client.dart'; import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; +import 'package:backend/utils/pan_crypto.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// GET /api/consultant/tax-info — Get consultant's tax information /// PUT /api/consultant/tax-info — Update tax information @@ -44,15 +44,15 @@ Future _handleGet(RequestContext context) async { ); } - final query = JsonQueryBuilder() - .model('ConsultantTaxInfo') - .action(QueryAction.findFirst) - .where({'consultantProfileId': consultantProfileId}).build(); - final taxInfo = await db.executor.executeQueryAsSingleMap(query); + final taxInfo = await db.prisma.consultantTaxInfo.findFirst( + where: ConsultantTaxInfoWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + ); return Response.json( body: { - 'data': taxInfo != null ? _toApiTaxInfo(taxInfo) : null, + 'data': taxInfo != null ? _toApiTaxInfo(taxInfo.toJson()) : null, }, ); } catch (e, stackTrace) { @@ -98,35 +98,36 @@ Future _handlePut(RequestContext context) async { } final body = await context.request.json() as Map; - final now = DateTime.now().toUtc().toIso8601String(); final taxResidency = body['taxResidency'] as String? ?? 'IN'; final panNumber = body['panNumber'] as String?; final gstNumber = body['gstNumber'] as String?; // Upsert tax info - final existingQuery = JsonQueryBuilder() - .model('ConsultantTaxInfo') - .action(QueryAction.findFirst) - .where({'consultantProfileId': consultantProfileId}).build(); - final existing = await db.executor.executeQueryAsSingleMap(existingQuery); + final existing = await db.prisma.consultantTaxInfo.findFirst( + where: ConsultantTaxInfoWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + ); if (existing != null) { - final updateQuery = JsonQueryBuilder() - .model('ConsultantTaxInfo') - .action(QueryAction.update) - .where({'id': existing['id']}).data({ - if (body.containsKey('panNumber')) 'panEncrypted': panNumber, - if (body.containsKey('panNumber')) 'panLast4': _last4(panNumber), - if (body.containsKey('gstNumber')) 'gstin': gstNumber, - if (body.containsKey('taxResidency')) 'country': taxResidency, - if (body.containsKey('taxResidency')) - 'isIndianResident': taxResidency.toUpperCase() == 'IN', - 'updatedAt': now, - }).build(); - final result = await db.executor.executeQueryAsSingleMap(updateQuery); + // Typed update auto-refreshes updatedAt — no manual timestamp needed. + final result = await db.prisma.consultantTaxInfo.update( + where: ConsultantTaxInfoWhereUniqueInput(id: existing.id), + data: UpdateConsultantTaxInfoInput( + panEncrypted: (body.containsKey('panNumber') && panNumber != null) + ? PanCrypto.encrypt(panNumber, keyHex: _panKey()) + : null, + panLast4: body.containsKey('panNumber') ? _last4(panNumber) : null, + gstin: body.containsKey('gstNumber') ? gstNumber : null, + country: body.containsKey('taxResidency') ? taxResidency : null, + isIndianResident: body.containsKey('taxResidency') + ? taxResidency.toUpperCase() == 'IN' + : null, + ), + ); return Response.json( body: { - 'data': result != null ? _toApiTaxInfo(result) : null, + 'data': _toApiTaxInfo(result.toJson()), }, ); } else { @@ -142,27 +143,23 @@ Future _handlePut(RequestContext context) async { ); } - final createQuery = JsonQueryBuilder() - .model('ConsultantTaxInfo') - .action(QueryAction.create) - .data({ - 'id': const Uuid().v4(), - 'consultantProfileId': consultantProfileId, - 'panEncrypted': panNumber, - 'panLast4': _last4(panNumber), - 'gstin': gstNumber, - 'country': taxResidency, - 'isIndianResident': taxResidency.toUpperCase() == 'IN', - 'panVerified': false, - 'gstinVerified': false, - 'createdAt': now, - 'updatedAt': now, - }).build(); - final result = await db.executor.executeQueryAsSingleMap(createQuery); + // Typed create autofills id/createdAt/updatedAt defaults. + final result = await db.prisma.consultantTaxInfo.create( + data: CreateConsultantTaxInfoInput( + consultantProfileId: consultantProfileId, + panEncrypted: PanCrypto.encrypt(panNumber, keyHex: _panKey()), + panLast4: _last4(panNumber), + gstin: gstNumber, + country: taxResidency, + isIndianResident: taxResidency.toUpperCase() == 'IN', + panVerified: false, + gstinVerified: false, + ), + ); return Response.json( statusCode: HttpStatus.created, body: { - 'data': result != null ? _toApiTaxInfo(result) : null, + 'data': _toApiTaxInfo(result.toJson()), }, ); } @@ -202,12 +199,37 @@ String? _last4(String? value) { return value.substring(value.length - 4); } +String _panKey() => io.Platform.environment['PAN_ENCRYPTION_KEY'] ?? ''; + +/// Decrypt the stored AES-256-GCM PAN ciphertext back to plaintext. +/// Tolerates legacy plaintext-bytes rows written before encryption landed. String? _panValue(dynamic value) { if (value == null) return null; - if (value is String) return value; - if (value is List) return utf8.decode(value); - if (value is List) { - return utf8.decode(value.cast()); + List? bytes; + if (value is List) { + bytes = value; + } else if (value is List) { + bytes = value.cast(); + } else if (value is String) { + return value; + } else { + return value.toString(); + } + try { + return PanCrypto.decrypt(bytes, keyHex: _panKey()); + } catch (e) { + // A decrypt failure is usually a legacy raw-UTF-8 row, but it can also be + // a wrong/rotated/absent key — which would silently degrade every PAN. + // Log so a systemic key misconfiguration is visible, then fall back to a + // best-effort plaintext decode rather than throwing on read. + SentryLogger.warning( + 'PAN decrypt failed ($e); falling back to plaintext decode', + context: 'TaxInfo._panValue', + ); + try { + return utf8.decode(bytes); + } catch (_) { + return null; + } } - return value.toString(); } diff --git a/backend/routes/api/consultant/tds-records/index.dart b/backend/routes/api/consultant/tds-records/index.dart index 4069625..b982ce6 100644 --- a/backend/routes/api/consultant/tds-records/index.dart +++ b/backend/routes/api/consultant/tds-records/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/consultant/tds-records — List TDS deduction records Future onRequest(RequestContext context) async { @@ -18,14 +17,15 @@ Future onRequest(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); final user = await db.users.findById(userId); - final consultantProfileId = - user?['consultantProfileId'] as String?; + final consultantProfileId = user?['consultantProfileId'] as String?; if (consultantProfileId == null) { return Response.json( statusCode: HttpStatus.badRequest, @@ -35,15 +35,16 @@ Future onRequest(RequestContext context) async { ); } - final query = JsonQueryBuilder() - .model('TDSRecord') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantProfileId}) - .build(); - final records = await db.executor.executeQueryAsMaps(query); + final records = await db.prisma.tDSRecord.findMany( + where: TDSRecordWhereInput( + consultantProfileId: StringFilter(equals: consultantProfileId), + ), + ); return Response.json( - body: {'data': records.map(serializeForJson).toList()}, + body: { + 'data': records.map((r) => serializeForJson(r.toJson())).toList(), + }, ); } catch (e, stackTrace) { await SentryLogger.severe( @@ -54,7 +55,9 @@ Future onRequest(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to load TDS records'}}, + body: { + 'error': {'message': 'Failed to load TDS records'} + }, ); } } diff --git a/backend/routes/api/consultants/[id]/availability.dart b/backend/routes/api/consultants/[id]/availability.dart index 671282c..6e65855 100644 --- a/backend/routes/api/consultants/[id]/availability.dart +++ b/backend/routes/api/consultants/[id]/availability.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:backend/database/database_client.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/consultants/:id/availability /// @@ -113,7 +112,7 @@ Future onRequest(RequestContext context, String id) async { if (planId != null) { // Fetch the plan to get duration final planDuration = await _getPlanDuration( - db.executor, + db.prisma, planId, planType, ); @@ -161,27 +160,44 @@ Future onRequest(RequestContext context, String id) async { /// For ConsultationPlan, uses `durationInHours`. /// For SubscriptionPlan/ClassPlan/WebinarPlan, uses `sessionDurationInHours`. Future _getPlanDuration( - QueryExecutor executor, + PrismaClient prisma, String planId, String planType, ) async { try { final String modelName; final String durationField; + final Map? result; switch (planType.toLowerCase()) { case 'subscription': modelName = 'SubscriptionPlan'; durationField = 'sessionDurationInHours'; + result = await prisma.subscriptionPlan.findFirstProjected( + where: SubscriptionPlanWhereInput(id: StringFilter(equals: planId)), + select: const [SubscriptionPlanScalarField.sessionDurationInHours], + ); case 'class': modelName = 'ClassPlan'; durationField = 'sessionDurationInHours'; + result = await prisma.classPlan.findFirstProjected( + where: ClassPlanWhereInput(id: StringFilter(equals: planId)), + select: const [ClassPlanScalarField.sessionDurationInHours], + ); case 'webinar': modelName = 'WebinarPlan'; durationField = 'durationInHours'; + result = await prisma.webinarPlan.findFirstProjected( + where: WebinarPlanWhereInput(id: StringFilter(equals: planId)), + select: const [WebinarPlanScalarField.durationInHours], + ); case 'consultation': modelName = 'ConsultationPlan'; durationField = 'durationInHours'; + result = await prisma.consultationPlan.findFirstProjected( + where: ConsultationPlanWhereInput(id: StringFilter(equals: planId)), + select: const [ConsultationPlanScalarField.durationInHours], + ); default: await SentryLogger.warning( 'Unknown plan type: $planType, falling back to ConsultationPlan', @@ -189,14 +205,12 @@ Future _getPlanDuration( ); modelName = 'ConsultationPlan'; durationField = 'durationInHours'; + result = await prisma.consultationPlan.findFirstProjected( + where: ConsultationPlanWhereInput(id: StringFilter(equals: planId)), + select: const [ConsultationPlanScalarField.durationInHours], + ); } - final query = JsonQueryBuilder() - .model(modelName) - .action(QueryAction.findUnique) - .selectFields([durationField]).where({'id': planId}).build(); - - final result = await executor.executeQueryAsSingleMap(query); if (result == null) { await SentryLogger.warning( 'Plan not found: model=$modelName, id=$planId', diff --git a/backend/routes/api/consultee/profile.dart b/backend/routes/api/consultee/profile.dart index a3d652b..be6c8b4 100644 --- a/backend/routes/api/consultee/profile.dart +++ b/backend/routes/api/consultee/profile.dart @@ -25,7 +25,9 @@ Future _handleGet(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -35,11 +37,20 @@ Future _handleGet(RequestContext context) async { if (profile == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Consultee profile not found'}}, + body: { + 'error': {'message': 'Consultee profile not found'} + }, ); } return Response.json(body: serializeForJson(profile)); + } on ArgumentError catch (e) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.error( 'Error in GET /api/consultee/profile', @@ -50,7 +61,9 @@ Future _handleGet(RequestContext context) async { return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to fetch consultee profile'}}, + body: { + 'error': {'message': 'Failed to fetch consultee profile'} + }, ); } } @@ -78,7 +91,9 @@ Future _handlePatch(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -89,7 +104,9 @@ Future _handlePatch(RequestContext context) async { if (existing == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Consultee profile not found'}}, + body: { + 'error': {'message': 'Consultee profile not found'} + }, ); } @@ -119,6 +136,13 @@ Future _handlePatch(RequestContext context) async { 'error': {'message': 'Invalid request body format'}, }, ); + } on ArgumentError catch (e) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.error( 'Error in PATCH /api/consultee/profile', @@ -129,7 +153,9 @@ Future _handlePatch(RequestContext context) async { return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to update consultee profile'}}, + body: { + 'error': {'message': 'Failed to update consultee profile'} + }, ); } } diff --git a/backend/routes/api/dashboard/consultant/[consultantId]/index.dart b/backend/routes/api/dashboard/consultant/[consultantId]/index.dart index 6f08bcb..74945ef 100644 --- a/backend/routes/api/dashboard/consultant/[consultantId]/index.dart +++ b/backend/routes/api/dashboard/consultant/[consultantId]/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/dashboard/consultant/:consultantId — Full consultant dashboard Future onRequest( @@ -21,7 +20,9 @@ Future onRequest( if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -30,50 +31,52 @@ Future onRequest( final user = await db.users.findById(userId); final role = user?['role'] as String?; final userCpId = user?['consultantProfileId'] as String?; - if (userCpId != consultantId && - role != 'STAFF' && - role != 'ADMIN') { + if (userCpId != consultantId && role != 'STAFF' && role != 'ADMIN') { return Response.json( statusCode: HttpStatus.forbidden, - body: {'error': {'message': 'Access denied'}}, + body: { + 'error': {'message': 'Access denied'} + }, ); } - // Fetch dashboard data in parallel - final appointmentsQuery = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantId}) - .build(); - - final activitiesQuery = JsonQueryBuilder() - .model('ActivityLog') - .action(QueryAction.findMany) - .where({'consultantProfileId': consultantId}) - .build(); + // Fetch dashboard data. + // Appointment has no consultantProfileId column — the consultant is + // linked through its allocated slots, so filter via that relation. + final appointments = await db.prisma.appointment.findMany( + where: AppointmentWhereInput( + slotsOfAppointment: SlotOfAppointmentListRelationFilter( + some: SlotOfAppointmentWhereInput( + consultantProfileId: StringFilter(equals: consultantId), + ), + ), + ), + ); - final appointments = - await db.executor.executeQueryAsMaps(appointmentsQuery); - final activities = - await db.executor.executeQueryAsMaps(activitiesQuery); + final activities = await db.prisma.activityLog.findMany( + where: ActivityLogWhereInput( + consultantProfileId: StringFilter(equals: consultantId), + ), + ); return Response.json( body: { 'data': { 'appointments': - appointments.map(serializeForJson).toList(), + appointments.map((a) => serializeForJson(a.toJson())).toList(), 'activities': - activities.map(serializeForJson).toList(), + activities.map((a) => serializeForJson(a.toJson())).toList(), }, }, ); } catch (e, stackTrace) { await SentryLogger.severe('Consultant dashboard failed', - context: 'ConsultantDashboard', - error: e, stackTrace: stackTrace); + context: 'ConsultantDashboard', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to load dashboard'}}, + body: { + 'error': {'message': 'Failed to load dashboard'} + }, ); } } diff --git a/backend/routes/api/dashboard/consultee/[consulteeId]/index.dart b/backend/routes/api/dashboard/consultee/[consulteeId]/index.dart index 15e0912..af45434 100644 --- a/backend/routes/api/dashboard/consultee/[consulteeId]/index.dart +++ b/backend/routes/api/dashboard/consultee/[consulteeId]/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/dashboard/consultee/:consulteeId — Full consultee dashboard Future onRequest( @@ -21,7 +20,9 @@ Future onRequest( if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -29,49 +30,46 @@ Future onRequest( final user = await db.users.findById(userId); final role = user?['role'] as String?; final userCeId = user?['consulteeProfileId'] as String?; - if (userCeId != consulteeId && - role != 'STAFF' && - role != 'ADMIN') { + if (userCeId != consulteeId && role != 'STAFF' && role != 'ADMIN') { return Response.json( statusCode: HttpStatus.forbidden, - body: {'error': {'message': 'Access denied'}}, + body: { + 'error': {'message': 'Access denied'} + }, ); } // Fetch bookings by type - final consultationsQuery = JsonQueryBuilder() - .model('Consultation') - .action(QueryAction.findMany) - .where({'requestedById': consulteeId}) - .build(); - final consultations = - await db.executor.executeQueryAsMaps(consultationsQuery); + final consultations = await db.prisma.consultation.findMany( + where: ConsultationWhereInput( + requestedById: StringFilter(equals: consulteeId), + ), + ); - final subscriptionsQuery = JsonQueryBuilder() - .model('Subscription') - .action(QueryAction.findMany) - .where({'requestedById': consulteeId}) - .build(); - final subscriptions = - await db.executor.executeQueryAsMaps(subscriptionsQuery); + final subscriptions = await db.prisma.subscription.findMany( + where: SubscriptionWhereInput( + requestedById: StringFilter(equals: consulteeId), + ), + ); return Response.json( body: { 'data': { 'consultations': - consultations.map(serializeForJson).toList(), + consultations.map((c) => serializeForJson(c.toJson())).toList(), 'subscriptions': - subscriptions.map(serializeForJson).toList(), + subscriptions.map((s) => serializeForJson(s.toJson())).toList(), }, }, ); } catch (e, stackTrace) { await SentryLogger.severe('Consultee dashboard failed', - context: 'ConsulteeDashboard', - error: e, stackTrace: stackTrace); + context: 'ConsulteeDashboard', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to load dashboard'}}, + body: { + 'error': {'message': 'Failed to load dashboard'} + }, ); } } diff --git a/backend/routes/api/domains/[id]/index.dart b/backend/routes/api/domains/[id]/index.dart index 4a6ed30..1998188 100644 --- a/backend/routes/api/domains/[id]/index.dart +++ b/backend/routes/api/domains/[id]/index.dart @@ -4,7 +4,6 @@ import 'package:backend/database/database_client.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/domains/:id — Single domain with its subdomains Future onRequest(RequestContext context, String id) async { @@ -15,33 +14,26 @@ Future onRequest(RequestContext context, String id) async { try { final db = context.read(); - final domainQuery = JsonQueryBuilder() - .model('Domain') - .action(QueryAction.findFirst) - .where({'id': id}) - .build(); - final domain = await db.executor.executeQueryAsSingleMap(domainQuery); + final domain = await db.prisma.domain.findUnique( + where: DomainWhereUniqueInput(id: id), + ); if (domain == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Domain not found'}}, + body: { + 'error': {'message': 'Domain not found'} + }, ); } - final subdomainQuery = JsonQueryBuilder() - .model('SubDomain') - .action(QueryAction.findMany) - .where({'domainId': id}) - .build(); - final subdomains = await db.executor.executeQueryAsMaps( - subdomainQuery, + final subdomains = await db.prisma.subDomain.findMany( + where: SubDomainWhereInput(domainId: StringFilter(equals: id)), ); - final result = - Map.from(serializeForJson(domain) as Map); + final result = Map.from(serializeForJson(domain.toJson())); result['subdomains'] = - subdomains.map(serializeForJson).toList(); + subdomains.map((s) => serializeForJson(s.toJson())).toList(); return Response.json(body: {'data': result}); } catch (e, stackTrace) { @@ -53,7 +45,9 @@ Future onRequest(RequestContext context, String id) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to get domain'}}, + body: { + 'error': {'message': 'Failed to get domain'} + }, ); } } diff --git a/backend/routes/api/onboarding/submit.dart b/backend/routes/api/onboarding/submit.dart index 496a854..68d5e2c 100644 --- a/backend/routes/api/onboarding/submit.dart +++ b/backend/routes/api/onboarding/submit.dart @@ -6,7 +6,6 @@ import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/professional_background_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// POST /api/onboarding/submit /// @@ -189,6 +188,13 @@ Future onRequest(RequestContext context) async { 'error': {'message': 'Invalid request body format'}, }, ); + } on ArgumentError catch (e) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.severe( 'Onboarding submission failed', @@ -333,11 +339,9 @@ Future _processConsultantOnboarding( await ProfessionalBackgroundUtils.createRecords( userId: userId, txn: txn, - workExperiences: - consultantProfile['workExperiences'] as List?, + workExperiences: consultantProfile['workExperiences'] as List?, education: consultantProfile['education'] as List?, - certifications: - consultantProfile['certifications'] as List?, + certifications: consultantProfile['certifications'] as List?, ); return profileId; diff --git a/backend/routes/api/payments/discounts/validate.dart b/backend/routes/api/payments/discounts/validate.dart index 0580d97..366abc0 100644 --- a/backend/routes/api/payments/discounts/validate.dart +++ b/backend/routes/api/payments/discounts/validate.dart @@ -4,7 +4,6 @@ import 'package:backend/database/database_client.dart'; import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// POST /api/payments/discounts/validate — Validate a discount code /// @@ -20,7 +19,9 @@ Future onRequest(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -31,18 +32,16 @@ Future onRequest(RequestContext context) async { if (code == null || code.isEmpty) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'code is required'}}, + body: { + 'error': {'message': 'code is required'} + }, ); } final db = context.read(); - final query = JsonQueryBuilder() - .model('DiscountCode') - .action(QueryAction.findFirst) - .where({'code': code}) - .build(); - final discount = - await db.executor.executeQueryAsSingleMap(query); + final discount = await db.prisma.discountCode.findFirst( + where: DiscountCodeWhereInput(code: StringFilter(equals: code)), + ); if (discount == null) { return Response.json( @@ -52,28 +51,23 @@ Future onRequest(RequestContext context) async { } // Check active - if (discount['isActive'] != true) { + if (!discount.isActive) { return Response.json( body: {'valid': false, 'message': 'Code is inactive'}, ); } // Check expiry - final expiresAt = discount['expiresAt']; - if (expiresAt != null) { - final expiry = expiresAt is DateTime - ? expiresAt - : DateTime.tryParse(expiresAt.toString()); - if (expiry != null && expiry.isBefore(DateTime.now().toUtc())) { - return Response.json( - body: {'valid': false, 'message': 'Code has expired'}, - ); - } + final expiry = discount.expiresAt; + if (expiry != null && expiry.isBefore(DateTime.now().toUtc())) { + return Response.json( + body: {'valid': false, 'message': 'Code has expired'}, + ); } // Check max uses - final maxUses = discount['maxUses'] as int?; - final currentUses = discount['currentUses'] as int? ?? 0; + final maxUses = discount.maxUses; + final currentUses = discount.currentUses; if (maxUses != null && currentUses >= maxUses) { return Response.json( body: { @@ -84,11 +78,9 @@ Future onRequest(RequestContext context) async { } // Calculate discount - final discountType = discount['discountType'] as String?; - final discountValue = - (discount['discountValue'] as num?)?.toDouble() ?? 0; - final maxDiscount = - (discount['maxDiscount'] as num?)?.toDouble(); + final discountType = discount.discountType.toJson(); + final discountValue = discount.discountValue.toDouble(); + final maxDiscount = discount.maxDiscount?.toDouble(); double? discountAmount; if (amount != null) { @@ -99,8 +91,7 @@ Future onRequest(RequestContext context) async { } } else { // FIXED_AMOUNT - discountAmount = - discountValue < amount ? discountValue : amount; + discountAmount = discountValue < amount ? discountValue : amount; } } @@ -116,11 +107,12 @@ Future onRequest(RequestContext context) async { ); } catch (e, stackTrace) { await SentryLogger.severe('Discount validation failed', - context: 'DiscountValidate', - error: e, stackTrace: stackTrace); + context: 'DiscountValidate', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to validate code'}}, + body: { + 'error': {'message': 'Failed to validate code'} + }, ); } } diff --git a/backend/routes/api/plans/classes/[id]/index.dart b/backend/routes/api/plans/classes/[id]/index.dart index 1c6cf69..5fecc6c 100644 --- a/backend/routes/api/plans/classes/[id]/index.dart +++ b/backend/routes/api/plans/classes/[id]/index.dart @@ -15,17 +15,20 @@ Future onRequest(RequestContext context, String id) async { if (plan == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Not found'}}, + body: { + 'error': {'message': 'Not found'} + }, ); } return Response.json(body: {'data': serializeForJson(plan)}); } catch (e, stackTrace) { await SentryLogger.severe('Get failed', - context: 'ClassPlanGet', - error: e, stackTrace: stackTrace); + context: 'ClassPlanGet', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed'}}, + body: { + 'error': {'message': 'Failed'} + }, ); } } @@ -36,7 +39,9 @@ Future onRequest(RequestContext context, String id) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); @@ -44,26 +49,30 @@ Future onRequest(RequestContext context, String id) async { if (plan == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Not found'}}, + body: { + 'error': {'message': 'Not found'} + }, ); } final user = await db.users.findById(userId); - if (user?['consultantProfileId'] != - plan['consultantProfileId']) { + if (user?['consultantProfileId'] != plan['consultantProfileId']) { return Response.json( statusCode: HttpStatus.forbidden, - body: {'error': {'message': 'Not your plan'}}, + body: { + 'error': {'message': 'Not your plan'} + }, ); } await db.plans.deleteClassPlan(id); return Response.json(body: {'message': 'Deleted'}); } catch (e, stackTrace) { await SentryLogger.severe('Delete failed', - context: 'ClassPlanDel', - error: e, stackTrace: stackTrace); + context: 'ClassPlanDel', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed'}}, + body: { + 'error': {'message': 'Failed'} + }, ); } } diff --git a/backend/routes/api/plans/classes/index.dart b/backend/routes/api/plans/classes/index.dart index 25ffa54..3915ae1 100644 --- a/backend/routes/api/plans/classes/index.dart +++ b/backend/routes/api/plans/classes/index.dart @@ -14,7 +14,9 @@ Future onRequest(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); @@ -23,7 +25,9 @@ Future onRequest(RequestContext context) async { if (cpId == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'Not a consultant'}}, + body: { + 'error': {'message': 'Not a consultant'} + }, ); } final plans = await db.plans.listClassPlans(cpId); @@ -32,11 +36,12 @@ Future onRequest(RequestContext context) async { ); } catch (e, stackTrace) { await SentryLogger.severe('List failed', - context: 'ClassPlansGet', - error: e, stackTrace: stackTrace); + context: 'ClassPlansGet', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed'}}, + body: { + 'error': {'message': 'Failed'} + }, ); } } @@ -47,7 +52,9 @@ Future onRequest(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); @@ -56,19 +63,24 @@ Future onRequest(RequestContext context) async { if (cpId == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'Not a consultant'}}, + body: { + 'error': {'message': 'Not a consultant'} + }, ); } - final body = - await context.request.json() as Map; + final body = await context.request.json() as Map; final title = body['title'] as String?; final price = (body['price'] as num?)?.toInt(); final maxP = (body['maxParticipants'] as num?)?.toInt(); final dur = (body['durationInMonths'] as num?)?.toInt(); - if (title == null || price == null || price <= 0 || - maxP == null || maxP <= 0 || - dur == null || dur < 1) { + if (title == null || + price == null || + price <= 0 || + maxP == null || + maxP <= 0 || + dur == null || + dur < 1) { return Response.json( statusCode: HttpStatus.badRequest, body: { @@ -87,15 +99,12 @@ Future onRequest(RequestContext context) async { durationInMonths: dur, price: price, maxParticipants: maxP, - meetingsPerWeek: - (body['meetingsPerWeek'] as num?)?.toInt() ?? 1, + meetingsPerWeek: (body['meetingsPerWeek'] as num?)?.toInt() ?? 1, sessionDurationInHours: - (body['sessionDurationInHours'] as num?)?.toDouble() ?? - 1.0, + (body['sessionDurationInHours'] as num?)?.toDouble() ?? 1.0, language: body['language'] as String?, level: body['level'] as String?, - recordingEnabled: - body['recordingEnabled'] as bool? ?? false, + recordingEnabled: body['recordingEnabled'] as bool? ?? false, ); return Response.json( @@ -106,16 +115,20 @@ Future onRequest(RequestContext context) async { return Response.json( statusCode: HttpStatus.badRequest, body: { - 'error': {'message': e.message?.toString() ?? 'Invalid value: ${e.invalidValue}'} + 'error': { + 'message': + e.message?.toString() ?? 'Invalid value: ${e.invalidValue}' + } }, ); } catch (e, stackTrace) { await SentryLogger.severe('Create failed', - context: 'ClassPlansPost', - error: e, stackTrace: stackTrace); + context: 'ClassPlansPost', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to create'}}, + body: { + 'error': {'message': 'Failed to create'} + }, ); } } diff --git a/backend/routes/api/plans/consultations/[id]/index.dart b/backend/routes/api/plans/consultations/[id]/index.dart index 91dc9ed..ca73526 100644 --- a/backend/routes/api/plans/consultations/[id]/index.dart +++ b/backend/routes/api/plans/consultations/[id]/index.dart @@ -26,7 +26,9 @@ Future _handleGet(RequestContext context, String id) async { if (plan == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Plan not found'}}, + body: { + 'error': {'message': 'Plan not found'} + }, ); } return Response.json(body: {'data': serializeForJson(plan)}); @@ -39,7 +41,9 @@ Future _handleGet(RequestContext context, String id) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to get plan'}}, + body: { + 'error': {'message': 'Failed to get plan'} + }, ); } } @@ -50,7 +54,9 @@ Future _handlePut(RequestContext context, String id) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -60,13 +66,14 @@ Future _handlePut(RequestContext context, String id) async { if (existing == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Plan not found'}}, + body: { + 'error': {'message': 'Plan not found'} + }, ); } final user = await db.users.findById(userId); - if (user?['consultantProfileId'] != - existing['consultantProfileId']) { + if (user?['consultantProfileId'] != existing['consultantProfileId']) { return Response.json( statusCode: HttpStatus.forbidden, body: { @@ -80,8 +87,7 @@ Future _handlePut(RequestContext context, String id) async { id: id, title: body['title'] as String?, description: body['description'] as String?, - durationInHours: - (body['durationInHours'] as num?)?.toDouble(), + durationInHours: (body['durationInHours'] as num?)?.toDouble(), price: (body['price'] as num?)?.toInt(), language: body['language'] as String?, level: body['level'] as String?, @@ -101,7 +107,9 @@ Future _handlePut(RequestContext context, String id) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to update plan'}}, + body: { + 'error': {'message': 'Failed to update plan'} + }, ); } } @@ -115,7 +123,9 @@ Future _handleDelete( if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -124,13 +134,14 @@ Future _handleDelete( if (existing == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Plan not found'}}, + body: { + 'error': {'message': 'Plan not found'} + }, ); } final user = await db.users.findById(userId); - if (user?['consultantProfileId'] != - existing['consultantProfileId']) { + if (user?['consultantProfileId'] != existing['consultantProfileId']) { return Response.json( statusCode: HttpStatus.forbidden, body: { @@ -150,7 +161,9 @@ Future _handleDelete( ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to delete plan'}}, + body: { + 'error': {'message': 'Failed to delete plan'} + }, ); } } diff --git a/backend/routes/api/plans/consultations/index.dart b/backend/routes/api/plans/consultations/index.dart index c85c8e1..8f7bd5c 100644 --- a/backend/routes/api/plans/consultations/index.dart +++ b/backend/routes/api/plans/consultations/index.dart @@ -23,14 +23,15 @@ Future _handleGet(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); final user = await db.users.findById(userId); - final consultantProfileId = - user?['consultantProfileId'] as String?; + final consultantProfileId = user?['consultantProfileId'] as String?; if (consultantProfileId == null) { return Response.json( statusCode: HttpStatus.badRequest, @@ -56,7 +57,9 @@ Future _handleGet(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to list plans'}}, + body: { + 'error': {'message': 'Failed to list plans'} + }, ); } } @@ -67,14 +70,15 @@ Future _handlePost(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); final user = await db.users.findById(userId); - final consultantProfileId = - user?['consultantProfileId'] as String?; + final consultantProfileId = user?['consultantProfileId'] as String?; if (consultantProfileId == null) { return Response.json( statusCode: HttpStatus.badRequest, @@ -92,7 +96,9 @@ Future _handlePost(RequestContext context) async { if (title == null || title.isEmpty) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'title is required'}}, + body: { + 'error': {'message': 'title is required'} + }, ); } if (durationInHours == null || @@ -110,7 +116,9 @@ Future _handlePost(RequestContext context) async { if (price == null || price <= 0) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'price must be > 0'}}, + body: { + 'error': {'message': 'price must be > 0'} + }, ); } @@ -133,7 +141,9 @@ Future _handlePost(RequestContext context) async { return Response.json( statusCode: HttpStatus.badRequest, body: { - 'error': {'message': e.message?.toString() ?? 'Invalid value: ${e.invalidValue}'} + 'error': { + 'message': e.message?.toString() ?? 'Invalid value: ${e.invalidValue}' + } }, ); } catch (e, stackTrace) { @@ -145,7 +155,9 @@ Future _handlePost(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to create plan'}}, + body: { + 'error': {'message': 'Failed to create plan'} + }, ); } } diff --git a/backend/routes/api/plans/subscriptions/[id]/index.dart b/backend/routes/api/plans/subscriptions/[id]/index.dart index 471035a..77509fd 100644 --- a/backend/routes/api/plans/subscriptions/[id]/index.dart +++ b/backend/routes/api/plans/subscriptions/[id]/index.dart @@ -15,7 +15,9 @@ Future onRequest(RequestContext context, String id) async { if (plan == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Plan not found'}}, + body: { + 'error': {'message': 'Plan not found'} + }, ); } return Response.json(body: {'data': serializeForJson(plan)}); @@ -24,7 +26,9 @@ Future onRequest(RequestContext context, String id) async { context: 'SubPlanGet', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed'}}, + body: { + 'error': {'message': 'Failed'} + }, ); } } @@ -35,7 +39,9 @@ Future onRequest(RequestContext context, String id) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); @@ -43,15 +49,18 @@ Future onRequest(RequestContext context, String id) async { if (plan == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Not found'}}, + body: { + 'error': {'message': 'Not found'} + }, ); } final user = await db.users.findById(userId); - if (user?['consultantProfileId'] != - plan['consultantProfileId']) { + if (user?['consultantProfileId'] != plan['consultantProfileId']) { return Response.json( statusCode: HttpStatus.forbidden, - body: {'error': {'message': 'Not your plan'}}, + body: { + 'error': {'message': 'Not your plan'} + }, ); } await db.plans.deleteSubscriptionPlan(id); @@ -61,7 +70,9 @@ Future onRequest(RequestContext context, String id) async { context: 'SubPlanDel', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed'}}, + body: { + 'error': {'message': 'Failed'} + }, ); } } diff --git a/backend/routes/api/plans/subscriptions/index.dart b/backend/routes/api/plans/subscriptions/index.dart index 217469b..a5db427 100644 --- a/backend/routes/api/plans/subscriptions/index.dart +++ b/backend/routes/api/plans/subscriptions/index.dart @@ -23,7 +23,9 @@ Future _handleGet(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -33,7 +35,9 @@ Future _handleGet(RequestContext context) async { if (cpId == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'Not a consultant'}}, + body: { + 'error': {'message': 'Not a consultant'} + }, ); } @@ -43,11 +47,12 @@ Future _handleGet(RequestContext context) async { ); } catch (e, stackTrace) { await SentryLogger.severe('List subscription plans failed', - context: 'SubscriptionPlansGet', - error: e, stackTrace: stackTrace); + context: 'SubscriptionPlansGet', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to list plans'}}, + body: { + 'error': {'message': 'Failed to list plans'} + }, ); } } @@ -58,7 +63,9 @@ Future _handlePost(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -68,7 +75,9 @@ Future _handlePost(RequestContext context) async { if (cpId == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'Not a consultant'}}, + body: { + 'error': {'message': 'Not a consultant'} + }, ); } @@ -77,9 +86,12 @@ Future _handlePost(RequestContext context) async { final durationInMonths = (body['durationInMonths'] as num?)?.toInt(); final price = (body['price'] as num?)?.toInt(); - if (title == null || title.isEmpty || - durationInMonths == null || durationInMonths < 1 || - price == null || price <= 0) { + if (title == null || + title.isEmpty || + durationInMonths == null || + durationInMonths < 1 || + price == null || + price <= 0) { return Response.json( statusCode: HttpStatus.badRequest, body: { @@ -113,16 +125,19 @@ Future _handlePost(RequestContext context) async { return Response.json( statusCode: HttpStatus.badRequest, body: { - 'error': {'message': e.message?.toString() ?? 'Invalid value: ${e.invalidValue}'} + 'error': { + 'message': e.message?.toString() ?? 'Invalid value: ${e.invalidValue}' + } }, ); } catch (e, stackTrace) { await SentryLogger.severe('Create subscription plan failed', - context: 'SubscriptionPlansPost', - error: e, stackTrace: stackTrace); + context: 'SubscriptionPlansPost', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to create plan'}}, + body: { + 'error': {'message': 'Failed to create plan'} + }, ); } } diff --git a/backend/routes/api/plans/webinars/[id]/index.dart b/backend/routes/api/plans/webinars/[id]/index.dart index d51a2cc..3368f04 100644 --- a/backend/routes/api/plans/webinars/[id]/index.dart +++ b/backend/routes/api/plans/webinars/[id]/index.dart @@ -15,17 +15,20 @@ Future onRequest(RequestContext context, String id) async { if (plan == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Not found'}}, + body: { + 'error': {'message': 'Not found'} + }, ); } return Response.json(body: {'data': serializeForJson(plan)}); } catch (e, stackTrace) { await SentryLogger.severe('Get failed', - context: 'WebinarPlanGet', - error: e, stackTrace: stackTrace); + context: 'WebinarPlanGet', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed'}}, + body: { + 'error': {'message': 'Failed'} + }, ); } } @@ -36,7 +39,9 @@ Future onRequest(RequestContext context, String id) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); @@ -44,26 +49,30 @@ Future onRequest(RequestContext context, String id) async { if (plan == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Not found'}}, + body: { + 'error': {'message': 'Not found'} + }, ); } final user = await db.users.findById(userId); - if (user?['consultantProfileId'] != - plan['consultantProfileId']) { + if (user?['consultantProfileId'] != plan['consultantProfileId']) { return Response.json( statusCode: HttpStatus.forbidden, - body: {'error': {'message': 'Not your plan'}}, + body: { + 'error': {'message': 'Not your plan'} + }, ); } await db.plans.deleteWebinarPlan(id); return Response.json(body: {'message': 'Deleted'}); } catch (e, stackTrace) { await SentryLogger.severe('Delete failed', - context: 'WebinarPlanDel', - error: e, stackTrace: stackTrace); + context: 'WebinarPlanDel', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed'}}, + body: { + 'error': {'message': 'Failed'} + }, ); } } diff --git a/backend/routes/api/plans/webinars/index.dart b/backend/routes/api/plans/webinars/index.dart index 9f1643d..02d6393 100644 --- a/backend/routes/api/plans/webinars/index.dart +++ b/backend/routes/api/plans/webinars/index.dart @@ -14,7 +14,9 @@ Future onRequest(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); @@ -23,7 +25,9 @@ Future onRequest(RequestContext context) async { if (cpId == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'Not a consultant'}}, + body: { + 'error': {'message': 'Not a consultant'} + }, ); } final plans = await db.plans.listWebinarPlans(cpId); @@ -32,11 +36,12 @@ Future onRequest(RequestContext context) async { ); } catch (e, stackTrace) { await SentryLogger.severe('List failed', - context: 'WebinarPlansGet', - error: e, stackTrace: stackTrace); + context: 'WebinarPlansGet', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed'}}, + body: { + 'error': {'message': 'Failed'} + }, ); } } @@ -47,7 +52,9 @@ Future onRequest(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); @@ -56,19 +63,24 @@ Future onRequest(RequestContext context) async { if (cpId == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'Not a consultant'}}, + body: { + 'error': {'message': 'Not a consultant'} + }, ); } - final body = - await context.request.json() as Map; + final body = await context.request.json() as Map; final title = body['title'] as String?; final price = (body['price'] as num?)?.toInt(); final maxP = (body['maxParticipants'] as num?)?.toInt(); final dur = (body['durationInHours'] as num?)?.toDouble(); - if (title == null || price == null || price <= 0 || - maxP == null || maxP <= 0 || - dur == null || dur <= 0) { + if (title == null || + price == null || + price <= 0 || + maxP == null || + maxP <= 0 || + dur == null || + dur <= 0) { return Response.json( statusCode: HttpStatus.badRequest, body: { @@ -89,8 +101,7 @@ Future onRequest(RequestContext context) async { maxParticipants: maxP, language: body['language'] as String?, level: body['level'] as String?, - recordingEnabled: - body['recordingEnabled'] as bool? ?? false, + recordingEnabled: body['recordingEnabled'] as bool? ?? false, ); return Response.json( @@ -101,16 +112,20 @@ Future onRequest(RequestContext context) async { return Response.json( statusCode: HttpStatus.badRequest, body: { - 'error': {'message': e.message?.toString() ?? 'Invalid value: ${e.invalidValue}'} + 'error': { + 'message': + e.message?.toString() ?? 'Invalid value: ${e.invalidValue}' + } }, ); } catch (e, stackTrace) { await SentryLogger.severe('Create failed', - context: 'WebinarPlansPost', - error: e, stackTrace: stackTrace); + context: 'WebinarPlansPost', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to create'}}, + body: { + 'error': {'message': 'Failed to create'} + }, ); } } diff --git a/backend/routes/api/referrals/code/index.dart b/backend/routes/api/referrals/code/index.dart index 406c448..fea876f 100644 --- a/backend/routes/api/referrals/code/index.dart +++ b/backend/routes/api/referrals/code/index.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'package:backend/database/database_client.dart'; diff --git a/backend/routes/api/slots/availability/custom/[id]/index.dart b/backend/routes/api/slots/availability/custom/[id]/index.dart index 999ca1f..2b57e33 100644 --- a/backend/routes/api/slots/availability/custom/[id]/index.dart +++ b/backend/routes/api/slots/availability/custom/[id]/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// PUT /api/slots/availability/custom/:id — Update /// DELETE /api/slots/availability/custom/:id — Delete @@ -30,7 +29,9 @@ Future _handle( if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -42,28 +43,27 @@ Future _handle( if (userCpId == null) { return Response.json( statusCode: HttpStatus.forbidden, - body: {'error': {'message': 'Not a consultant'}}, + body: { + 'error': {'message': 'Not a consultant'} + }, ); } - final slotQuery = JsonQueryBuilder() - .model('SlotOfAvailabilityCustom') - .action(QueryAction.findFirst) - .where({'id': id}) - .build(); - final slot = - await db.executor.executeQueryAsSingleMap(slotQuery); + final slot = await db.prisma.slotOfAvailabilityCustom.findUnique( + where: SlotOfAvailabilityCustomWhereUniqueInput(id: id), + ); - if (slot == null || slot['consultantProfileId'] != userCpId) { + if (slot == null || slot.consultantProfileId != userCpId) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Slot not found'}}, + body: { + 'error': {'message': 'Slot not found'} + }, ); } if (method == HttpMethod.put) { - final body = - await context.request.json() as Map; + final body = await context.request.json() as Map; final updated = await db.slots.updateCustomSlot( id: id, startsAt: body['startsAt'] as String?, @@ -71,8 +71,7 @@ Future _handle( ); return Response.json( body: { - 'data': - updated != null ? serializeForJson(updated) : null, + 'data': updated != null ? serializeForJson(updated) : null, }, ); } @@ -89,7 +88,9 @@ Future _handle( ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Operation failed'}}, + body: { + 'error': {'message': 'Operation failed'} + }, ); } } diff --git a/backend/routes/api/slots/availability/custom/index.dart b/backend/routes/api/slots/availability/custom/index.dart index 17bcc29..7231b7c 100644 --- a/backend/routes/api/slots/availability/custom/index.dart +++ b/backend/routes/api/slots/availability/custom/index.dart @@ -19,8 +19,7 @@ Future onRequest(RequestContext context) async { Future _handleGet(RequestContext context) async { try { - final cpId = - context.request.uri.queryParameters['consultantProfileId']; + final cpId = context.request.uri.queryParameters['consultantProfileId']; if (cpId == null) { return Response.json( statusCode: HttpStatus.badRequest, @@ -40,11 +39,12 @@ Future _handleGet(RequestContext context) async { ); } catch (e, stackTrace) { await SentryLogger.severe('List custom slots failed', - context: 'CustomSlotsGet', - error: e, stackTrace: stackTrace); + context: 'CustomSlotsGet', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to list slots'}}, + body: { + 'error': {'message': 'Failed to list slots'} + }, ); } } @@ -55,7 +55,9 @@ Future _handlePost(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -65,7 +67,9 @@ Future _handlePost(RequestContext context) async { if (cpId == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'Not a consultant'}}, + body: { + 'error': {'message': 'Not a consultant'} + }, ); } @@ -107,11 +111,12 @@ Future _handlePost(RequestContext context) async { ); } catch (e, stackTrace) { await SentryLogger.severe('Create custom slot failed', - context: 'CustomSlotsPost', - error: e, stackTrace: stackTrace); + context: 'CustomSlotsPost', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to create slot'}}, + body: { + 'error': {'message': 'Failed to create slot'} + }, ); } } diff --git a/backend/routes/api/slots/availability/weekly/[id]/index.dart b/backend/routes/api/slots/availability/weekly/[id]/index.dart index 4c0c5d8..34e4ae3 100644 --- a/backend/routes/api/slots/availability/weekly/[id]/index.dart +++ b/backend/routes/api/slots/availability/weekly/[id]/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// PUT /api/slots/availability/weekly/:id — Update /// DELETE /api/slots/availability/weekly/:id — Delete @@ -30,7 +29,9 @@ Future _handle( if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -42,35 +43,38 @@ Future _handle( if (userCpId == null) { return Response.json( statusCode: HttpStatus.forbidden, - body: {'error': {'message': 'Not a consultant'}}, + body: { + 'error': {'message': 'Not a consultant'} + }, ); } - final slotQuery = JsonQueryBuilder() - .model('SlotOfAvailabilityWeekly') - .action(QueryAction.findFirst) - .where({'id': id}) - .build(); - final slot = - await db.executor.executeQueryAsSingleMap(slotQuery); + final slot = await db.prisma.slotOfAvailabilityWeekly.findFirst( + where: SlotOfAvailabilityWeeklyWhereInput( + id: StringFilter(equals: id), + ), + ); if (slot == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Slot not found'}}, + body: { + 'error': {'message': 'Slot not found'} + }, ); } - if (slot['consultantProfileId'] != userCpId) { + if (slot.consultantProfileId != userCpId) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Slot not found'}}, + body: { + 'error': {'message': 'Slot not found'} + }, ); } if (method == HttpMethod.put) { - final body = - await context.request.json() as Map; + final body = await context.request.json() as Map; final updated = await db.slots.updateWeeklySlot( id: id, startDay: body['startDay'] as String?, @@ -80,8 +84,7 @@ Future _handle( ); return Response.json( body: { - 'data': - updated != null ? serializeForJson(updated) : null, + 'data': updated != null ? serializeForJson(updated) : null, }, ); } @@ -89,6 +92,13 @@ Future _handle( // DELETE await db.slots.deleteWeeklySlot(id); return Response.json(body: {'message': 'Slot deleted'}); + } on ArgumentError catch (e) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.severe( 'Weekly slot operation failed', @@ -98,7 +108,9 @@ Future _handle( ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Operation failed'}}, + body: { + 'error': {'message': 'Operation failed'} + }, ); } } diff --git a/backend/routes/api/slots/availability/weekly/index.dart b/backend/routes/api/slots/availability/weekly/index.dart index 1cbf10a..9da4560 100644 --- a/backend/routes/api/slots/availability/weekly/index.dart +++ b/backend/routes/api/slots/availability/weekly/index.dart @@ -19,8 +19,7 @@ Future onRequest(RequestContext context) async { Future _handleGet(RequestContext context) async { try { - final cpId = - context.request.uri.queryParameters['consultantProfileId']; + final cpId = context.request.uri.queryParameters['consultantProfileId']; if (cpId == null) { return Response.json( statusCode: HttpStatus.badRequest, @@ -38,6 +37,13 @@ Future _handleGet(RequestContext context) async { return Response.json( body: {'data': slots.map(serializeForJson).toList()}, ); + } on ArgumentError catch (e) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.severe( 'List weekly slots failed', @@ -47,7 +53,9 @@ Future _handleGet(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to list slots'}}, + body: { + 'error': {'message': 'Failed to list slots'} + }, ); } } @@ -58,7 +66,9 @@ Future _handlePost(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -68,7 +78,9 @@ Future _handlePost(RequestContext context) async { if (cpId == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'Not a consultant'}}, + body: { + 'error': {'message': 'Not a consultant'} + }, ); } @@ -79,8 +91,10 @@ Future _handlePost(RequestContext context) async { final endTimeUtc = (body['endTimeUtc'] as num?)?.toInt(); final utcOffsetMinutes = (body['utcOffsetMinutes'] as num?)?.toInt() ?? 0; - if (startDay == null || endDay == null || - startTimeUtc == null || endTimeUtc == null) { + if (startDay == null || + endDay == null || + startTimeUtc == null || + endTimeUtc == null) { return Response.json( statusCode: HttpStatus.badRequest, body: { @@ -93,8 +107,10 @@ Future _handlePost(RequestContext context) async { } // Validate time range (0-1439 minutes) - if (startTimeUtc < 0 || startTimeUtc > 1439 || - endTimeUtc < 0 || endTimeUtc > 1439) { + if (startTimeUtc < 0 || + startTimeUtc > 1439 || + endTimeUtc < 0 || + endTimeUtc > 1439) { return Response.json( statusCode: HttpStatus.badRequest, body: { @@ -118,6 +134,13 @@ Future _handlePost(RequestContext context) async { statusCode: HttpStatus.created, body: {'data': serializeForJson(slot)}, ); + } on ArgumentError catch (e) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.severe( 'Create weekly slot failed', @@ -127,7 +150,9 @@ Future _handlePost(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to create slot'}}, + body: { + 'error': {'message': 'Failed to create slot'} + }, ); } } diff --git a/backend/routes/api/staff/feedbacks/[feedbackId]/index.dart b/backend/routes/api/staff/feedbacks/[feedbackId]/index.dart index e3eeb1b..2ab5197 100644 --- a/backend/routes/api/staff/feedbacks/[feedbackId]/index.dart +++ b/backend/routes/api/staff/feedbacks/[feedbackId]/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// PUT /api/staff/feedbacks/:feedbackId — Update feedback status Future onRequest( @@ -41,11 +40,9 @@ Future onRequest( } if (context.request.method == HttpMethod.get) { - final query = JsonQueryBuilder() - .model('Feedback') - .action(QueryAction.findFirst) - .where({'id': feedbackId}).build(); - final feedback = await db.executor.executeQueryAsSingleMap(query); + final feedback = await db.prisma.feedback.findFirst( + where: FeedbackWhereInput(id: StringFilter(equals: feedbackId)), + ); if (feedback == null) { return Response.json( statusCode: HttpStatus.notFound, @@ -56,27 +53,35 @@ Future onRequest( } return Response.json( - body: {'data': serializeForJson(feedback)}, + body: {'data': serializeForJson(feedback.toJson())}, ); } final body = await context.request.json() as Map; - final data = { - 'updatedAt': DateTime.now().toUtc().toIso8601String(), - }; - if (body.containsKey('status')) data['status'] = body['status']; + // Typed update auto-refreshes updatedAt — no manual timestamp needed. + FeedbackStatus? status; + if (body.containsKey('status')) { + final matches = + FeedbackStatus.values.where((e) => e.toJson() == body['status']); + if (matches.isEmpty) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': 'Invalid status: ${body['status']}'}, + }, + ); + } + status = matches.first; + } - final query = JsonQueryBuilder() - .model('Feedback') - .action(QueryAction.update) - .where({'id': feedbackId}) - .data(data) - .build(); - final updated = await db.executor.executeQueryAsSingleMap(query); + final updated = await db.prisma.feedback.update( + where: FeedbackWhereUniqueInput(id: feedbackId), + data: UpdateFeedbackInput(status: status), + ); return Response.json( body: { - 'data': updated != null ? serializeForJson(updated) : null, + 'data': serializeForJson(updated.toJson()), }, ); } catch (e, stackTrace) { diff --git a/backend/routes/api/staff/feedbacks/index.dart b/backend/routes/api/staff/feedbacks/index.dart index ab020f8..983e45b 100644 --- a/backend/routes/api/staff/feedbacks/index.dart +++ b/backend/routes/api/staff/feedbacks/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/staff/feedbacks — List feedbacks for staff review Future onRequest(RequestContext context) async { @@ -18,7 +17,9 @@ Future onRequest(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -28,28 +29,29 @@ Future onRequest(RequestContext context) async { if (role != 'STAFF' && role != 'ADMIN') { return Response.json( statusCode: HttpStatus.forbidden, - body: {'error': {'message': 'Staff access required'}}, + body: { + 'error': {'message': 'Staff access required'} + }, ); } - final query = JsonQueryBuilder() - .model('Feedback') - .action(QueryAction.findMany) - .where({}) - .orderBy({'createdAt': 'desc'}) - .build(); - final feedbacks = await db.executor.executeQueryAsMaps(query); + final feedbacks = await db.prisma.feedback.findMany( + orderBy: const FeedbackOrderByInput(createdAt: SortOrder.desc), + ); return Response.json( - body: {'data': feedbacks.map(serializeForJson).toList()}, + body: { + 'data': feedbacks.map((f) => serializeForJson(f.toJson())).toList(), + }, ); } catch (e, stackTrace) { await SentryLogger.severe('Staff feedbacks failed', - context: 'StaffFeedbacks', - error: e, stackTrace: stackTrace); + context: 'StaffFeedbacks', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to list feedbacks'}}, + body: { + 'error': {'message': 'Failed to list feedbacks'} + }, ); } } diff --git a/backend/routes/api/staff/moderation/profiles/[verificationId]/index.dart b/backend/routes/api/staff/moderation/profiles/[verificationId]/index.dart index 62c3aba..9afe682 100644 --- a/backend/routes/api/staff/moderation/profiles/[verificationId]/index.dart +++ b/backend/routes/api/staff/moderation/profiles/[verificationId]/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/staff/moderation/profiles/:verificationId — Details /// PUT /api/staff/moderation/profiles/:verificationId — Review @@ -57,12 +56,10 @@ Future _handleGet( ); } - final verificationQuery = JsonQueryBuilder() - .model('ConsultantProfileVerification') - .action(QueryAction.findUnique) - .where({'id': verificationId}).build(); final verification = - await db.executor.executeQueryAsSingleMap(verificationQuery); + await db.prisma.consultantProfileVerification.findUnique( + where: ConsultantProfileVerificationWhereUniqueInput(id: verificationId), + ); if (verification == null) { return Response.json( @@ -75,22 +72,19 @@ Future _handleGet( final docs = await db.consultantVerifications.getDocuments(verificationId); - final json = serializeForJson(verification); + final json = serializeForJson(verification.toJson()); json['documents'] = docs.map(serializeForJson).toList(); - final consultantProfileId = verification['consultantProfileId'] as String?; - if (consultantProfileId != null) { - final profileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findUnique) - .where({'id': consultantProfileId}).build(); - final profile = await db.executor.executeQueryAsSingleMap(profileQuery); - final consultantUserId = profile?['userId'] as String?; - if (consultantUserId != null) { - final consultantUser = await db.users.findById(consultantUserId); - json['consultantName'] = consultantUser?['name']; - json['consultantEmail'] = consultantUser?['email']; - } + final profile = await db.prisma.consultantProfile.findUnique( + where: ConsultantProfileWhereUniqueInput( + id: verification.consultantProfileId, + ), + ); + final consultantUserId = profile?.userId; + if (consultantUserId != null) { + final consultantUser = await db.users.findById(consultantUserId); + json['consultantName'] = consultantUser?['name']; + json['consultantEmail'] = consultantUser?['email']; } return Response.json(body: {'data': json}); @@ -167,11 +161,9 @@ Future _handlePut( ); } - final existingQuery = JsonQueryBuilder() - .model('ConsultantProfileVerification') - .action(QueryAction.findUnique) - .where({'id': verificationId}).build(); - final existing = await db.executor.executeQueryAsSingleMap(existingQuery); + final existing = await db.prisma.consultantProfileVerification.findUnique( + where: ConsultantProfileVerificationWhereUniqueInput(id: verificationId), + ); if (existing == null) { return Response.json( @@ -182,65 +174,51 @@ Future _handlePut( ); } - final now = DateTime.now().toUtc().toIso8601String(); - final updateData = { - 'status': status.name.toUpperCase(), - 'reviewedAt': now, - 'reviewedById': userId, - 'updatedAt': now, - }; - if (body.containsKey('reviewNotes')) { - updateData['reviewNotes'] = body['reviewNotes']; - } - if (body.containsKey('rejectionReason')) { - updateData['rejectionReason'] = body['rejectionReason']; - } - if (body.containsKey('feedbackDetails')) { - updateData['feedbackDetails'] = body['feedbackDetails']; - } - - final updateQuery = JsonQueryBuilder() - .model('ConsultantProfileVerification') - .action(QueryAction.update) - .where({'id': verificationId}) - .data(updateData) - .build(); - final updated = await db.executor.executeQueryAsSingleMap(updateQuery); + // Typed update auto-refreshes updatedAt — no manual timestamp needed. + final updated = await db.prisma.consultantProfileVerification.update( + where: ConsultantProfileVerificationWhereUniqueInput(id: verificationId), + data: UpdateConsultantProfileVerificationInput( + status: status, + reviewedAt: DateTime.now().toUtc(), + reviewedById: userId, + reviewNotes: body['reviewNotes'] as String?, + rejectionReason: body['rejectionReason'] as String?, + feedbackDetails: body['feedbackDetails'] as String?, + ), + ); - final consultantProfileId = existing['consultantProfileId'] as String?; - if (consultantProfileId != null) { - final profileUpdate = { - 'updatedAt': now, - }; - switch (status) { - case ProfileVerificationStatus.approved: - profileUpdate['isVerified'] = true; - profileUpdate['verificationStatus'] = 'VERIFIED'; - case ProfileVerificationStatus.rejected: - profileUpdate['isVerified'] = false; - profileUpdate['verificationStatus'] = 'REJECTED'; - case ProfileVerificationStatus.needsInfo: - profileUpdate['isVerified'] = false; - profileUpdate['verificationStatus'] = 'UNDER_REVIEW'; - case ProfileVerificationStatus.pending: - case ProfileVerificationStatus.superseded: - break; - } + bool? isVerified; + ConsultantVerificationStatus? verificationStatus; + switch (status) { + case ProfileVerificationStatus.approved: + isVerified = true; + verificationStatus = ConsultantVerificationStatus.verified; + case ProfileVerificationStatus.rejected: + isVerified = false; + verificationStatus = ConsultantVerificationStatus.rejected; + case ProfileVerificationStatus.needsInfo: + isVerified = false; + verificationStatus = ConsultantVerificationStatus.underReview; + case ProfileVerificationStatus.pending: + case ProfileVerificationStatus.superseded: + break; + } - if (profileUpdate.length > 1) { - final profileUpdateQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.update) - .where({'id': consultantProfileId}) - .data(profileUpdate) - .build(); - await db.executor.executeMutation(profileUpdateQuery); - } + if (verificationStatus != null) { + await db.prisma.consultantProfile.update( + where: ConsultantProfileWhereUniqueInput( + id: existing.consultantProfileId, + ), + data: UpdateConsultantProfileInput( + isVerified: isVerified, + verificationStatus: verificationStatus, + ), + ); } return Response.json( body: { - 'data': updated != null ? serializeForJson(updated) : null, + 'data': serializeForJson(updated.toJson()), }, ); } catch (e, stackTrace) { diff --git a/backend/routes/api/staff/moderation/profiles/index.dart b/backend/routes/api/staff/moderation/profiles/index.dart index 62e22a6..807bfde 100644 --- a/backend/routes/api/staff/moderation/profiles/index.dart +++ b/backend/routes/api/staff/moderation/profiles/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/staff/moderation/profiles — Pending verification requests Future onRequest(RequestContext context) async { @@ -36,29 +35,28 @@ Future onRequest(RequestContext context) async { ); } - final query = JsonQueryBuilder() - .model('ConsultantProfileVerification') - .action(QueryAction.findMany) - .where({'status': 'PENDING'}).build(); - final verifications = await db.executor.executeQueryAsMaps(query); + final verifications = + await db.prisma.consultantProfileVerification.findMany( + where: const ConsultantProfileVerificationWhereInput( + status: ProfileVerificationStatusFilter( + equals: ProfileVerificationStatus.pending, + ), + ), + ); final enriched = >[]; for (final verification in verifications) { - final json = serializeForJson(verification); - final consultantProfileId = - verification['consultantProfileId'] as String?; - if (consultantProfileId != null) { - final profileQuery = JsonQueryBuilder() - .model('ConsultantProfile') - .action(QueryAction.findUnique) - .where({'id': consultantProfileId}).build(); - final profile = await db.executor.executeQueryAsSingleMap(profileQuery); - final consultantUserId = profile?['userId'] as String?; - if (consultantUserId != null) { - final consultantUser = await db.users.findById(consultantUserId); - json['consultantName'] = consultantUser?['name']; - json['consultantEmail'] = consultantUser?['email']; - } + final json = serializeForJson(verification.toJson()); + final profile = await db.prisma.consultantProfile.findUnique( + where: ConsultantProfileWhereUniqueInput( + id: verification.consultantProfileId, + ), + ); + final consultantUserId = profile?.userId; + if (consultantUserId != null) { + final consultantUser = await db.users.findById(consultantUserId); + json['consultantName'] = consultantUser?['name']; + json['consultantEmail'] = consultantUser?['email']; } enriched.add(json); } diff --git a/backend/routes/api/staff/stats.dart b/backend/routes/api/staff/stats.dart index c2fa83d..1ebdc6f 100644 --- a/backend/routes/api/staff/stats.dart +++ b/backend/routes/api/staff/stats.dart @@ -4,7 +4,6 @@ import 'package:backend/database/database_client.dart'; import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/staff/stats — Basic metrics for staff dashboard Future onRequest(RequestContext context) async { @@ -17,7 +16,9 @@ Future onRequest(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -28,34 +29,33 @@ Future onRequest(RequestContext context) async { if (role != 'STAFF' && role != 'ADMIN') { return Response.json( statusCode: HttpStatus.forbidden, - body: {'error': {'message': 'Staff access required'}}, + body: { + 'error': {'message': 'Staff access required'} + }, ); } - // Gather basic metrics - final openTicketsQuery = JsonQueryBuilder() - .model('SupportTicket') - .action(QueryAction.count) - .where({'status': 'OPEN'}) - .build(); - final openTickets = - await db.executor.executeCount(openTicketsQuery); + // Gather basic metrics (typed PrismaClient delegates) + final openTickets = await db.prisma.supportTicket.count( + where: const SupportTicketWhereInput( + status: SupportTicketStatusFilter(equals: SupportTicketStatus.open), + ), + ); - final pendingVerificationsQuery = JsonQueryBuilder() - .model('ConsultantProfileVerification') - .action(QueryAction.count) - .where({'status': 'PENDING'}) - .build(); final pendingVerifications = - await db.executor.executeCount(pendingVerificationsQuery); + await db.prisma.consultantProfileVerification.count( + where: const ConsultantProfileVerificationWhereInput( + status: ProfileVerificationStatusFilter( + equals: ProfileVerificationStatus.pending, + ), + ), + ); - final pendingFeedbackQuery = JsonQueryBuilder() - .model('Feedback') - .action(QueryAction.count) - .where({'status': 'PENDING'}) - .build(); - final pendingFeedback = - await db.executor.executeCount(pendingFeedbackQuery); + final pendingFeedback = await db.prisma.feedback.count( + where: const FeedbackWhereInput( + status: FeedbackStatusFilter(equals: FeedbackStatus.pending), + ), + ); return Response.json( body: { @@ -75,7 +75,9 @@ Future onRequest(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to load stats'}}, + body: { + 'error': {'message': 'Failed to load stats'} + }, ); } } diff --git a/backend/routes/api/staff/support-tickets/[ticketId]/index.dart b/backend/routes/api/staff/support-tickets/[ticketId]/index.dart index 7e4966f..83f7da3 100644 --- a/backend/routes/api/staff/support-tickets/[ticketId]/index.dart +++ b/backend/routes/api/staff/support-tickets/[ticketId]/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/staff/support-tickets/:ticketId — Ticket details /// PUT /api/staff/support-tickets/:ticketId — Update ticket status @@ -20,7 +19,9 @@ Future onRequest( if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -30,58 +31,72 @@ Future onRequest( if (role != 'STAFF' && role != 'ADMIN') { return Response.json( statusCode: HttpStatus.forbidden, - body: {'error': {'message': 'Staff access required'}}, + body: { + 'error': {'message': 'Staff access required'} + }, ); } if (method == HttpMethod.get) { - final query = JsonQueryBuilder() - .model('SupportTicket') - .action(QueryAction.findFirst) - .where({'id': ticketId}) - .build(); - final ticket = - await db.executor.executeQueryAsSingleMap(query); + final ticket = await db.prisma.supportTicket.findFirst( + where: SupportTicketWhereInput(id: StringFilter(equals: ticketId)), + ); if (ticket == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Ticket not found'}}, + body: { + 'error': {'message': 'Ticket not found'} + }, ); } return Response.json( - body: {'data': serializeForJson(ticket)}, + body: {'data': serializeForJson(ticket.toJson())}, ); } if (method == HttpMethod.put) { - final body = - await context.request.json() as Map; - final data = { - 'updatedAt': - DateTime.now().toUtc().toIso8601String(), - }; + final body = await context.request.json() as Map; + // Typed update auto-refreshes updatedAt — no manual timestamp needed. + SupportTicketStatus? status; if (body.containsKey('status')) { - data['status'] = body['status']; + final matches = SupportTicketStatus.values + .where((e) => e.toJson() == body['status']); + if (matches.isEmpty) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': 'Invalid status: ${body['status']}'}, + }, + ); + } + status = matches.first; } + SupportPriority? priority; if (body.containsKey('priority')) { - data['priority'] = body['priority']; - } - if (body.containsKey('assignedToId')) { - data['assignedToId'] = body['assignedToId']; + final matches = + SupportPriority.values.where((e) => e.toJson() == body['priority']); + if (matches.isEmpty) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': 'Invalid priority: ${body['priority']}'}, + }, + ); + } + priority = matches.first; } - final query = JsonQueryBuilder() - .model('SupportTicket') - .action(QueryAction.update) - .where({'id': ticketId}) - .data(data) - .build(); - final updated = - await db.executor.executeQueryAsSingleMap(query); + final updated = await db.prisma.supportTicket.update( + where: SupportTicketWhereUniqueInput(id: ticketId), + data: UpdateSupportTicketInput( + status: status, + priority: priority, + assignedToId: body['assignedToId'] as String?, + ), + ); return Response.json( body: { - 'data': - updated != null ? serializeForJson(updated) : null, + 'data': serializeForJson(updated.toJson()), }, ); } @@ -89,11 +104,12 @@ Future onRequest( return Response(statusCode: HttpStatus.methodNotAllowed); } catch (e, stackTrace) { await SentryLogger.severe('Staff ticket operation failed', - context: 'StaffTicket', - error: e, stackTrace: stackTrace); + context: 'StaffTicket', error: e, stackTrace: stackTrace); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Operation failed'}}, + body: { + 'error': {'message': 'Operation failed'} + }, ); } } diff --git a/backend/routes/api/staff/support-tickets/[ticketId]/responses.dart b/backend/routes/api/staff/support-tickets/[ticketId]/responses.dart index 39f9990..c11317d 100644 --- a/backend/routes/api/staff/support-tickets/[ticketId]/responses.dart +++ b/backend/routes/api/staff/support-tickets/[ticketId]/responses.dart @@ -5,8 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; -import 'package:uuid/uuid.dart'; /// GET /api/staff/support-tickets/:ticketId/responses — List /// POST /api/staff/support-tickets/:ticketId/responses — Add response @@ -14,7 +12,6 @@ Future onRequest( RequestContext context, String ticketId, ) async { - const uuid = Uuid(); try { final userId = getUserIdFromToken(context); if (userId == null) { @@ -39,14 +36,14 @@ Future onRequest( } if (context.request.method == HttpMethod.get) { - final query = JsonQueryBuilder() - .model('SupportResponse') - .action(QueryAction.findMany) - .where({'supportTicketId': ticketId}).build(); - final responses = await db.executor.executeQueryAsMaps(query); + final responses = await db.prisma.supportResponse.findMany( + where: SupportResponseWhereInput( + supportTicketId: StringFilter(equals: ticketId), + ), + ); return Response.json( body: { - 'data': responses.map(serializeForJson).toList(), + 'data': responses.map((r) => serializeForJson(r.toJson())).toList(), }, ); } @@ -63,31 +60,26 @@ Future onRequest( ); } - final now = DateTime.now().toUtc().toIso8601String(); - final query = JsonQueryBuilder() - .model('SupportResponse') - .action(QueryAction.create) - .data({ - 'id': uuid.v4(), - 'supportTicketId': ticketId, - 'userId': userId, - 'message': message, - 'isInternal': false, - 'createdAt': now, - 'updatedAt': now, - }).build(); - final result = await db.executor.executeQueryAsSingleMap(query); + // Typed create autofills id/createdAt/updatedAt defaults. + final result = await db.prisma.supportResponse.create( + data: CreateSupportResponseInput( + supportTicketId: ticketId, + userId: userId, + message: message, + isInternal: false, + ), + ); - final ticketUpdateQuery = JsonQueryBuilder() - .model('SupportTicket') - .action(QueryAction.update) - .where({'id': ticketId}).data({'updatedAt': now}).build(); - await db.executor.executeMutation(ticketUpdateQuery); + // Typed update auto-refreshes updatedAt. + await db.prisma.supportTicket.update( + where: SupportTicketWhereUniqueInput(id: ticketId), + data: const UpdateSupportTicketInput(), + ); return Response.json( statusCode: HttpStatus.created, body: { - 'data': result != null ? serializeForJson(result) : null, + 'data': serializeForJson(result.toJson()), }, ); } diff --git a/backend/routes/api/staff/support-tickets/index.dart b/backend/routes/api/staff/support-tickets/index.dart index 8547060..9e80783 100644 --- a/backend/routes/api/staff/support-tickets/index.dart +++ b/backend/routes/api/staff/support-tickets/index.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/staff/support-tickets — List all support tickets for staff Future onRequest(RequestContext context) async { @@ -18,7 +17,9 @@ Future onRequest(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -28,24 +29,41 @@ Future onRequest(RequestContext context) async { if (role != 'STAFF' && role != 'ADMIN') { return Response.json( statusCode: HttpStatus.forbidden, - body: {'error': {'message': 'Staff access required'}}, + body: { + 'error': {'message': 'Staff access required'} + }, ); } final status = context.request.uri.queryParameters['status']; - final where = {}; - if (status != null) where['status'] = status; + SupportTicketStatus? statusFilter; + if (status != null) { + final matches = + SupportTicketStatus.values.where((e) => e.toJson() == status); + if (matches.isEmpty) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': 'Invalid status: $status'}, + }, + ); + } + statusFilter = matches.first; + } - final query = JsonQueryBuilder() - .model('SupportTicket') - .action(QueryAction.findMany) - .where(where) - .orderBy({'createdAt': 'desc'}) - .build(); - final tickets = await db.executor.executeQueryAsMaps(query); + final tickets = await db.prisma.supportTicket.findMany( + where: statusFilter != null + ? SupportTicketWhereInput( + status: SupportTicketStatusFilter(equals: statusFilter), + ) + : null, + orderBy: const SupportTicketOrderByInput(createdAt: SortOrder.desc), + ); return Response.json( - body: {'data': tickets.map(serializeForJson).toList()}, + body: { + 'data': tickets.map((t) => serializeForJson(t.toJson())).toList(), + }, ); } catch (e, stackTrace) { await SentryLogger.severe( @@ -56,7 +74,9 @@ Future onRequest(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to load tickets'}}, + body: { + 'error': {'message': 'Failed to load tickets'} + }, ); } } diff --git a/backend/routes/api/stream/add-member/index.dart b/backend/routes/api/stream/add-member/index.dart index 060d0a6..4f3572f 100644 --- a/backend/routes/api/stream/add-member/index.dart +++ b/backend/routes/api/stream/add-member/index.dart @@ -52,9 +52,8 @@ Future _handleAddMember(RequestContext context) async { final body = await context.request.json() as Map; final channelType = body['channelType'] as String? ?? 'team'; final channelId = body['channelId'] as String?; - final memberIds = (body['memberIds'] as List?) - ?.map((e) => e as String) - .toList(); + final memberIds = + (body['memberIds'] as List?)?.map((e) => e as String).toList(); // Validate required fields if (channelId == null || channelId.isEmpty) { diff --git a/backend/routes/api/stream/create-group-channel/index.dart b/backend/routes/api/stream/create-group-channel/index.dart index 3c50e1d..8600db6 100644 --- a/backend/routes/api/stream/create-group-channel/index.dart +++ b/backend/routes/api/stream/create-group-channel/index.dart @@ -62,9 +62,8 @@ Future _handleCreateGroupChannel(RequestContext context) async { final body = await context.request.json() as Map; final channelId = body['channelId'] as String?; final channelName = body['channelName'] as String?; - final memberIds = (body['memberIds'] as List?) - ?.map((e) => e as String) - .toList(); + final memberIds = + (body['memberIds'] as List?)?.map((e) => e as String).toList(); final extraData = body['extraData'] as Map?; // Validate required fields diff --git a/backend/routes/api/stream/fix-group-channels/index.dart b/backend/routes/api/stream/fix-group-channels/index.dart index 63b0464..dc5e3bb 100644 --- a/backend/routes/api/stream/fix-group-channels/index.dart +++ b/backend/routes/api/stream/fix-group-channels/index.dart @@ -4,7 +4,6 @@ import 'package:backend/database/database_client.dart'; import 'package:backend/services/stream_service.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// POST /api/stream/fix-group-channels /// @@ -56,54 +55,86 @@ Future _handleFixChannels(RequestContext context) async { ); // Query all webinar/class appointments with related data - final query = JsonQueryBuilder() - .model('Appointment') - .action(QueryAction.findMany) - .where({ - 'OR': [ - {'appointmentType': 'WEBINAR'}, - {'appointmentType': 'CLASS'}, - ], - }) - .include({ - 'webinar': { - 'include': { - 'webinarPlan': { - 'include': { - 'consultantProfile': { - 'include': {'user': true}, - }, - }, - }, - }, - }, - 'class': { - 'include': { - 'classPlan': { - 'include': { - 'consultantProfile': { - 'include': {'user': true}, - }, - }, - }, - }, - }, - 'slots': { - 'include': {'user': true}, - }, - }) - .build(); - - final appointments = await db.executor.executeQueryAsMaps(query); + // (typed delegate + AppointmentInclude; relation names follow the + // re-synced schema: classRef / slotsOfAppointment). + final appointments = await db.prisma.appointment.findMany( + where: const AppointmentWhereInput( + OR: [ + AppointmentWhereInput( + appointmentType: + AppointmentsTypeFilter(equals: AppointmentsType.webinar), + ), + AppointmentWhereInput( + appointmentType: + AppointmentsTypeFilter(equals: AppointmentsType.classValue), + ), + ], + ), + include: const AppointmentInclude( + webinar: WebinarInclude( + webinarPlan: WebinarPlanInclude( + consultantProfile: ConsultantProfileInclude( + user: UserInclude(), + ), + ), + ), + classRef: ClassModelInclude( + classPlan: ClassPlanInclude( + consultantProfile: ConsultantProfileInclude( + user: UserInclude(), + ), + ), + ), + slotsOfAppointment: SlotOfAppointmentInclude( + user: UserInclude(), + ), + ), + ); SentryLogger.info( 'Found ${appointments.length} webinar/class appointments to process', context: 'FixGroupChannelsRoute', ); + // Pre-pass: batch-upsert every UNIQUE user once (instructors repeat + // across hundreds of appointments). Previously each appointment upserted + // its 2 users twice (4 UpdateUsers calls each) — ~1,700 calls per run, + // which tripped Stream's 300 UpdateUsers/min rate limit. Now it's a + // handful of batched calls for the whole migration. + final uniqueUsers = >{}; + for (final appointment in appointments) { + final instructor = + appointment.webinar?.webinarPlan?.consultantProfile?.user ?? + appointment.classRef?.classPlan?.consultantProfile?.user; + if (instructor != null) { + uniqueUsers[instructor.id] = { + 'id': instructor.id, + 'name': instructor.name, + if (instructor.image != null) 'image': instructor.image, + }; + } + final slots = appointment.slotsOfAppointment; + if (slots != null && slots.isNotEmpty) { + final users = slots.first.user; + if (users != null && users.isNotEmpty) { + final participant = users.first; + uniqueUsers[participant.id] = { + 'id': participant.id, + 'name': participant.name, + if (participant.image != null) 'image': participant.image, + }; + } + } + } + await streamService.upsertUsers(uniqueUsers.values.toList()); + SentryLogger.info( + 'Pre-upserted ${uniqueUsers.length} unique users in batch', + context: 'FixGroupChannelsRoute', + ); + for (final appointment in appointments) { - final webinar = appointment['webinar'] as Map?; - final classRecord = appointment['class'] as Map?; + final webinar = appointment.webinar; + final classRecord = appointment.classRef; String? channelId; String? channelName; @@ -117,43 +148,40 @@ Future _handleFixChannels(RequestContext context) async { String? participantImage; if (webinar != null) { - final webinarId = appointment['webinarId'] as String; + final webinarId = appointment.webinarId!; channelId = 'webinar_$webinarId'; programType = 'WEBINAR'; programId = webinarId; - final plan = webinar['webinarPlan'] as Map?; - channelName = plan?['title'] as String? ?? 'Webinar'; - final profile = plan?['consultantProfile'] as Map?; - final user = profile?['user'] as Map?; - instructorUserId = user?['id'] as String?; - instructorName = user?['name'] as String?; - instructorImage = user?['image'] as String?; + final plan = webinar.webinarPlan; + channelName = plan?.title ?? 'Webinar'; + final user = plan?.consultantProfile?.user; + instructorUserId = user?.id; + instructorName = user?.name; + instructorImage = user?.image; } else if (classRecord != null) { - final classId = appointment['classId'] as String; + final classId = appointment.classId!; channelId = 'class_$classId'; programType = 'CLASS'; programId = classId; - final plan = classRecord['classPlan'] as Map?; - channelName = plan?['title'] as String? ?? 'Class'; - final profile = plan?['consultantProfile'] as Map?; - final user = profile?['user'] as Map?; - instructorUserId = user?['id'] as String?; - instructorName = user?['name'] as String?; - instructorImage = user?['image'] as String?; + final plan = classRecord.classPlan; + channelName = plan?.title ?? 'Class'; + final user = plan?.consultantProfile?.user; + instructorUserId = user?.id; + instructorName = user?.name; + instructorImage = user?.image; } // Get participant from slots - final slots = appointment['slots'] as List?; + final slots = appointment.slotsOfAppointment; if (slots != null && slots.isNotEmpty) { - final firstSlot = slots.first as Map; - final users = firstSlot['user'] as List?; + final users = slots.first.user; if (users != null && users.isNotEmpty) { - final participant = users.first as Map; - participantUserId = participant['id'] as String?; - participantName = participant['name'] as String?; - participantImage = participant['image'] as String?; + final participant = users.first; + participantUserId = participant.id; + participantName = participant.name; + participantImage = participant.image; } } @@ -187,6 +215,8 @@ Future _handleFixChannels(RequestContext context) async { instructorImage: instructorImage, participantName: participantName, participantImage: participantImage, + // All unique users were batch-upserted in the pre-pass above. + ensureUsers: false, ); fixed.add(channelId); diff --git a/backend/routes/api/stream/recordings/[id]/index.dart b/backend/routes/api/stream/recordings/[id]/index.dart index 23f9b06..9f4acaf 100644 --- a/backend/routes/api/stream/recordings/[id]/index.dart +++ b/backend/routes/api/stream/recordings/[id]/index.dart @@ -27,7 +27,9 @@ Future onRequest(RequestContext context, String id) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -37,7 +39,9 @@ Future onRequest(RequestContext context, String id) async { if (recording == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Recording not found'}}, + body: { + 'error': {'message': 'Recording not found'} + }, ); } @@ -51,7 +55,9 @@ Future onRequest(RequestContext context, String id) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to get recording'}}, + body: { + 'error': {'message': 'Failed to get recording'} + }, ); } } diff --git a/backend/routes/api/support/[ticketId]/attachments.dart b/backend/routes/api/support/[ticketId]/attachments.dart index 31edb18..ffb6ffc 100644 --- a/backend/routes/api/support/[ticketId]/attachments.dart +++ b/backend/routes/api/support/[ticketId]/attachments.dart @@ -5,7 +5,6 @@ import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/support/:ticketId/attachments — List attachments /// POST /api/support/:ticketId/attachments — Add attachment @@ -38,23 +37,23 @@ Future _handleGet( if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); - // Fetch attachments via raw query (SupportTicket has no - // typed attachment relation in generated code) - final query = JsonQueryBuilder() - .model('SupportTicketAttachment') - .action(QueryAction.findMany) - .where({'supportTicketId': ticketId}) - .build(); - final attachments = await db.executor.executeQueryAsMaps(query); + // FK column is `ticketId` (schema re-sync renamed it from supportTicketId). + final attachments = await db.prisma.supportTicketAttachment.findMany( + where: SupportTicketAttachmentWhereInput( + ticketId: StringFilter(equals: ticketId), + ), + ); return Response.json( body: { - 'data': attachments.map(serializeForJson).toList(), + 'data': attachments.map((a) => serializeForJson(a.toJson())).toList(), }, ); } catch (e, stackTrace) { @@ -66,7 +65,9 @@ Future _handleGet( ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to list attachments'}}, + body: { + 'error': {'message': 'Failed to list attachments'} + }, ); } } @@ -80,7 +81,9 @@ Future _handlePost( if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -107,27 +110,25 @@ Future _handlePost( ); } - final now = DateTime.now().toUtc().toIso8601String(); final db = context.read(); - final query = JsonQueryBuilder() - .model('SupportTicketAttachment') - .action(QueryAction.create) - .data({ - 'supportTicketId': ticketId, - 'fileName': fileName, - 'fileUrl': fileUrl, - 'mimeType': mimeType, - 'fileSize': fileSize, - 'storagePath': storagePath, - 'uploadedBy': userId, - 'uploadedAt': now, - }).build(); - - final result = await db.executor.executeQueryAsSingleMap(query); + // Typed create autofills id/uploadedAt defaults. The schema has no + // `uploadedBy` column (dropped during the re-sync) and requires + // `originalName` — use the client-provided fileName for it. + final result = await db.prisma.supportTicketAttachment.create( + data: CreateSupportTicketAttachmentInput( + ticketId: ticketId, + fileName: fileName, + originalName: fileName, + fileUrl: fileUrl, + mimeType: mimeType, + fileSize: fileSize, + storagePath: storagePath, + ), + ); return Response.json( statusCode: HttpStatus.created, - body: {'data': result != null ? serializeForJson(result) : null}, + body: {'data': serializeForJson(result.toJson())}, ); } catch (e, stackTrace) { await SentryLogger.severe( @@ -138,7 +139,9 @@ Future _handlePost( ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to add attachment'}}, + body: { + 'error': {'message': 'Failed to add attachment'} + }, ); } } diff --git a/backend/routes/api/tags/index.dart b/backend/routes/api/tags/index.dart index 298c395..82e8d9e 100644 --- a/backend/routes/api/tags/index.dart +++ b/backend/routes/api/tags/index.dart @@ -1,10 +1,10 @@ import 'dart:io'; import 'package:backend/database/database_client.dart'; +import 'package:backend/generated/index.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/tags — List tags with optional search filter Future onRequest(RequestContext context) async { @@ -16,20 +16,17 @@ Future onRequest(RequestContext context) async { final search = context.request.uri.queryParameters['search']; final db = context.read(); - final where = {}; - if (search != null && search.isNotEmpty) { - where['name'] = {'contains': search, 'mode': 'insensitive'}; - } - - final query = JsonQueryBuilder() - .model('Tag') - .action(QueryAction.findMany) - .where(where) - .build(); - final tags = await db.executor.executeQueryAsMaps(query); + // Typed delegate (prisma_flutter_connector v0.7.0) — replaces the raw + // JsonQueryBuilder path. Compile-time-checked model, field, and filter. + final tags = await db.prisma.tag.findMany( + where: (search != null && search.isNotEmpty) + ? TagWhereInput( + name: StringFilter(contains: search, mode: 'insensitive')) + : null, + ); return Response.json( - body: {'data': tags.map(serializeForJson).toList()}, + body: {'data': tags.map((t) => serializeForJson(t.toJson())).toList()}, ); } catch (e, stackTrace) { await SentryLogger.severe( @@ -40,7 +37,9 @@ Future onRequest(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to load tags'}}, + body: { + 'error': {'message': 'Failed to load tags'} + }, ); } } diff --git a/backend/routes/api/topics/index.dart b/backend/routes/api/topics/index.dart index 8b4c990..836c5d9 100644 --- a/backend/routes/api/topics/index.dart +++ b/backend/routes/api/topics/index.dart @@ -4,7 +4,6 @@ import 'package:backend/database/database_client.dart'; import 'package:backend/utils/json_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/topics — List topics with optional search filter Future onRequest(RequestContext context) async { @@ -16,20 +15,18 @@ Future onRequest(RequestContext context) async { final search = context.request.uri.queryParameters['search']; final db = context.read(); - final where = {}; - if (search != null && search.isNotEmpty) { - where['name'] = {'contains': search, 'mode': 'insensitive'}; - } - - final query = JsonQueryBuilder() - .model('Topic') - .action(QueryAction.findMany) - .where(where) - .build(); - final topics = await db.executor.executeQueryAsMaps(query); + final topics = await db.prisma.topic.findMany( + where: (search != null && search.isNotEmpty) + ? TopicWhereInput( + name: StringFilter(contains: search, mode: 'insensitive'), + ) + : null, + ); return Response.json( - body: {'data': topics.map(serializeForJson).toList()}, + body: { + 'data': topics.map((t) => serializeForJson(t.toJson())).toList(), + }, ); } catch (e, stackTrace) { await SentryLogger.severe( @@ -40,7 +37,9 @@ Future onRequest(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to load topics'}}, + body: { + 'error': {'message': 'Failed to load topics'} + }, ); } } diff --git a/backend/routes/api/trials/[trialId]/index.dart b/backend/routes/api/trials/[trialId]/index.dart index 5eccfe7..0ba76d6 100644 --- a/backend/routes/api/trials/[trialId]/index.dart +++ b/backend/routes/api/trials/[trialId]/index.dart @@ -39,7 +39,9 @@ Future _handleGet( if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -48,7 +50,9 @@ Future _handleGet( if (trial == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Trial not found'}}, + body: { + 'error': {'message': 'Trial not found'} + }, ); } @@ -62,7 +66,9 @@ Future _handleGet( ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to get trial'}}, + body: { + 'error': {'message': 'Failed to get trial'} + }, ); } } @@ -76,7 +82,9 @@ Future _handlePut( if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -85,7 +93,9 @@ Future _handlePut( if (statusStr == null) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': 'status is required'}}, + body: { + 'error': {'message': 'status is required'} + }, ); } @@ -103,7 +113,9 @@ Future _handlePut( } on FormatException catch (e) { return Response.json( statusCode: HttpStatus.badRequest, - body: {'error': {'message': e.message}}, + body: { + 'error': {'message': e.message} + }, ); } catch (e, stackTrace) { await SentryLogger.severe( @@ -114,7 +126,9 @@ Future _handlePut( ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to update trial'}}, + body: { + 'error': {'message': 'Failed to update trial'} + }, ); } } diff --git a/backend/routes/api/trials/index.dart b/backend/routes/api/trials/index.dart index b515081..4fdfb34 100644 --- a/backend/routes/api/trials/index.dart +++ b/backend/routes/api/trials/index.dart @@ -30,7 +30,9 @@ Future _handleGet(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -38,10 +40,8 @@ Future _handleGet(RequestContext context) async { final user = await db.users.findById(userId); // Return trials based on role - final consultantProfileId = - user?['consultantProfileId'] as String?; - final consulteeProfileId = - user?['consulteeProfileId'] as String?; + final consultantProfileId = user?['consultantProfileId'] as String?; + final consulteeProfileId = user?['consulteeProfileId'] as String?; List> trials; if (consultantProfileId != null) { @@ -64,7 +64,9 @@ Future _handleGet(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to list trials'}}, + body: { + 'error': {'message': 'Failed to list trials'} + }, ); } } @@ -75,14 +77,15 @@ Future _handlePost(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } final db = context.read(); final user = await db.users.findById(userId); - final consulteeProfileId = - user?['consulteeProfileId'] as String?; + final consulteeProfileId = user?['consulteeProfileId'] as String?; if (consulteeProfileId == null) { return Response.json( @@ -94,10 +97,8 @@ Future _handlePost(RequestContext context) async { } final body = await context.request.json() as Map; - final consultantProfileId = - body['consultantProfileId'] as String?; - final subscriptionPlanId = - body['subscriptionPlanId'] as String?; + final consultantProfileId = body['consultantProfileId'] as String?; + final subscriptionPlanId = body['subscriptionPlanId'] as String?; final notes = body['notes'] as String?; if (consultantProfileId == null || subscriptionPlanId == null) { @@ -122,8 +123,7 @@ Future _handlePost(RequestContext context) async { statusCode: HttpStatus.conflict, body: { 'error': { - 'message': - 'You already have a trial with this consultant', + 'message': 'You already have a trial with this consultant', }, }, ); @@ -149,7 +149,9 @@ Future _handlePost(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to create trial'}}, + body: { + 'error': {'message': 'Failed to create trial'} + }, ); } } diff --git a/backend/routes/api/user/[id]/index.dart b/backend/routes/api/user/[id]/index.dart index 7272391..ad645d3 100644 --- a/backend/routes/api/user/[id]/index.dart +++ b/backend/routes/api/user/[id]/index.dart @@ -90,6 +90,13 @@ Future _handleGet(RequestContext context, String id) async { user.remove('password'); return Response.json(body: {'data': serializeForJson(user)}); + } on ArgumentError catch (e) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.severe( 'Failed to get user profile', @@ -210,6 +217,13 @@ Future _handlePut(RequestContext context, String id) async { 'error': {'message': 'Invalid request body format'}, }, ); + } on ArgumentError catch (e) { + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': e.message?.toString() ?? 'Invalid input'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.severe( 'Failed to update user profile', diff --git a/backend/routes/api/user/[id]/professional-background/index.dart b/backend/routes/api/user/[id]/professional-background/index.dart index 1df68f4..7d2add5 100644 --- a/backend/routes/api/user/[id]/professional-background/index.dart +++ b/backend/routes/api/user/[id]/professional-background/index.dart @@ -3,10 +3,8 @@ import 'dart:io'; import 'package:backend/database/database_client.dart'; import 'package:backend/utils/auth_utils.dart'; import 'package:backend/utils/json_utils.dart'; -import 'package:backend/utils/professional_background_utils.dart'; import 'package:backend/utils/sentry_logger.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart'; /// GET /api/user/:id/professional-background /// Returns work experiences, education, and certifications. @@ -40,35 +38,27 @@ Future _handleGet(RequestContext context, String id) async { final db = context.read(); - final weQuery = JsonQueryBuilder() - .model('WorkExperience') - .action(QueryAction.findMany) - .where({'userId': userId}) - .build(); - final workExperiences = await db.executor.executeQueryAsMaps(weQuery); - - final eduQuery = JsonQueryBuilder() - .model('Education') - .action(QueryAction.findMany) - .where({'userId': userId}) - .build(); - final education = await db.executor.executeQueryAsMaps(eduQuery); - - final certQuery = JsonQueryBuilder() - .model('Certification') - .action(QueryAction.findMany) - .where({'userId': userId}) - .build(); - final certifications = await db.executor.executeQueryAsMaps(certQuery); + final workExperiences = await db.prisma.workExperience.findMany( + where: WorkExperienceWhereInput(userId: StringFilter(equals: userId)), + ); + + final education = await db.prisma.education.findMany( + where: EducationWhereInput(userId: StringFilter(equals: userId)), + ); + + final certifications = await db.prisma.certification.findMany( + where: CertificationWhereInput(userId: StringFilter(equals: userId)), + ); return Response.json( body: { 'data': { 'workExperiences': - workExperiences.map(serializeForJson).toList(), - 'education': education.map(serializeForJson).toList(), + workExperiences.map((w) => serializeForJson(w.toJson())).toList(), + 'education': + education.map((e) => serializeForJson(e.toJson())).toList(), 'certifications': - certifications.map(serializeForJson).toList(), + certifications.map((c) => serializeForJson(c.toJson())).toList(), }, }, ); @@ -103,19 +93,102 @@ Future _handlePut(RequestContext context, String id) async { final body = await context.request.json() as Map; final db = context.read(); - await db.executeInTransaction((txn) async { - await ProfessionalBackgroundUtils.replaceRecords( - userId: userId, - txn: txn, - workExperiences: body['workExperiences'] as List?, - education: body['education'] as List?, - certifications: body['certifications'] as List?, + // Replace all records atomically: delete existing, then re-create from + // the request body (typed delegates inside a $transaction). + await db.prisma.$transaction((tx) async { + await tx.workExperience.deleteMany( + where: WorkExperienceWhereInput(userId: StringFilter(equals: userId)), ); + await tx.education.deleteMany( + where: EducationWhereInput(userId: StringFilter(equals: userId)), + ); + await tx.certification.deleteMany( + where: CertificationWhereInput(userId: StringFilter(equals: userId)), + ); + + final workExperiences = body['workExperiences'] as List?; + if (workExperiences != null) { + for (final we in workExperiences) { + final item = we as Map; + await tx.workExperience.create( + data: CreateWorkExperienceInput( + userId: userId, + company: item['company'] as String, + companyDomain: item['companyDomain'] as String?, + title: item['title'] as String, + location: item['location'] as String?, + startDate: DateTime.parse(item['startDate'] as String), + endDate: item['endDate'] != null + ? DateTime.parse(item['endDate'] as String) + : null, + isCurrent: (item['isCurrent'] as bool?) ?? false, + description: item['description'] as String?, + ), + ); + } + } + + final education = body['education'] as List?; + if (education != null) { + for (final edu in education) { + final item = edu as Map; + await tx.education.create( + data: CreateEducationInput( + userId: userId, + institution: item['institution'] as String, + institutionDomain: item['institutionDomain'] as String?, + degree: item['degree'] as String, + fieldOfStudy: item['fieldOfStudy'] as String?, + startYear: (item['startYear'] as num?)?.toInt(), + endYear: (item['endYear'] as num?)?.toInt(), + grade: item['grade'] as String?, + activities: item['activities'] as String?, + description: item['description'] as String?, + ), + ); + } + } + + final certifications = body['certifications'] as List?; + if (certifications != null) { + for (final cert in certifications) { + final item = cert as Map; + await tx.certification.create( + data: CreateCertificationInput( + userId: userId, + name: item['name'] as String, + issuingOrganization: item['issuingOrganization'] as String, + issueDate: DateTime.parse(item['issueDate'] as String), + expiryDate: item['expiryDate'] != null + ? DateTime.parse(item['expiryDate'] as String) + : null, + credentialId: item['credentialId'] as String?, + credentialUrl: item['credentialUrl'] as String?, + ), + ); + } + } }); return Response.json( body: {'message': 'Professional background updated'}, ); + } on FormatException catch (e) { + // Malformed date / field in the replace payload -> validation error. + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': 'Invalid field format: ${e.message}'}, + }, + ); + } on TypeError catch (_) { + // Missing required field (e.g. a null where a String is expected). + return Response.json( + statusCode: HttpStatus.badRequest, + body: { + 'error': {'message': 'Missing or invalid required field'}, + }, + ); } catch (e, stackTrace) { await SentryLogger.severe( 'Failed to update professional background', diff --git a/backend/routes/api/waitlist/[id]/index.dart b/backend/routes/api/waitlist/[id]/index.dart index f8c9b38..6d1c5b4 100644 --- a/backend/routes/api/waitlist/[id]/index.dart +++ b/backend/routes/api/waitlist/[id]/index.dart @@ -29,7 +29,9 @@ Future _fetchAndAuthorize( if (entry == null) { return Response.json( statusCode: HttpStatus.notFound, - body: {'error': {'message': 'Waitlist entry not found'}}, + body: { + 'error': {'message': 'Waitlist entry not found'} + }, ); } @@ -54,7 +56,9 @@ Future _handleGet(RequestContext context, String id) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -71,7 +75,9 @@ Future _handleGet(RequestContext context, String id) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to get waitlist entry'}}, + body: { + 'error': {'message': 'Failed to get waitlist entry'} + }, ); } } @@ -82,7 +88,9 @@ Future _handleDelete(RequestContext context, String id) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -103,7 +111,9 @@ Future _handleDelete(RequestContext context, String id) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to leave waitlist'}}, + body: { + 'error': {'message': 'Failed to leave waitlist'} + }, ); } } diff --git a/backend/routes/api/waitlist/index.dart b/backend/routes/api/waitlist/index.dart index af04515..8388692 100644 --- a/backend/routes/api/waitlist/index.dart +++ b/backend/routes/api/waitlist/index.dart @@ -25,7 +25,9 @@ Future _handleGet(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -44,7 +46,9 @@ Future _handleGet(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to load waitlist'}}, + body: { + 'error': {'message': 'Failed to load waitlist'} + }, ); } } @@ -55,7 +59,9 @@ Future _handlePost(RequestContext context) async { if (userId == null) { return Response.json( statusCode: HttpStatus.unauthorized, - body: {'error': {'message': 'Unauthorized'}}, + body: { + 'error': {'message': 'Unauthorized'} + }, ); } @@ -94,7 +100,9 @@ Future _handlePost(RequestContext context) async { ); return Response.json( statusCode: HttpStatus.internalServerError, - body: {'error': {'message': 'Failed to join waitlist'}}, + body: { + 'error': {'message': 'Failed to join waitlist'} + }, ); } } diff --git a/backend/scripts/jqb-gate.sh b/backend/scripts/jqb-gate.sh new file mode 100755 index 0000000..9f97185 --- /dev/null +++ b/backend/scripts/jqb-gate.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# JQB retirement ratchet (Phase B guardrail). +# +# Fails CI if the number of raw JsonQueryBuilder / findManyRaw / findFirstRaw +# sites in application code RISES above the recorded baseline. The baseline is +# only ever lowered (as repositories are migrated to typed delegates), so JQB +# can shrink but never creep back. +# +# Update the baselines below whenever you legitimately reduce the counts. +set -euo pipefail +cd "$(dirname "$0")/.." + +# Baselines — lower these as Phase B progresses. Never raise them. +JQB_BASELINE=0 +RAW_BASELINE=0 + +jqb=$( (grep -rn "JsonQueryBuilder()" lib/database routes/ lib/services lib/route_handlers 2>/dev/null || true) | wc -l | tr -d ' ') +raw=$( (grep -rn "\.findManyRaw\|\.findFirstRaw" lib/database routes/ lib/services lib/route_handlers 2>/dev/null || true) | wc -l | tr -d ' ') + +echo "JsonQueryBuilder sites: $jqb (baseline $JQB_BASELINE)" +echo "findManyRaw/findFirstRaw sites: $raw (baseline $RAW_BASELINE)" + +fail=0 +if [ "$jqb" -gt "$JQB_BASELINE" ]; then + echo "::error:: JsonQueryBuilder count rose to $jqb (> $JQB_BASELINE). Use typed delegates." + fail=1 +fi +if [ "$raw" -gt "$RAW_BASELINE" ]; then + echo "::error:: findManyRaw/findFirstRaw count rose to $raw (> $RAW_BASELINE). Use typed include." + fail=1 +fi + +if [ "$jqb" -lt "$JQB_BASELINE" ] || [ "$raw" -lt "$RAW_BASELINE" ]; then + echo "Nice — counts dropped. Lower the baselines in scripts/jqb-gate.sh to lock in the win." +fi + +exit $fail diff --git a/backend/scripts/regenerate-build.sh b/backend/scripts/regenerate-build.sh new file mode 100755 index 0000000..dbcded7 --- /dev/null +++ b/backend/scripts/regenerate-build.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# Regenerate the backend's generated code. +# +# ./scripts/regenerate-build.sh # build_runner only (freezed/json) +# ./scripts/regenerate-build.sh --prisma # also regenerate the Prisma client +# +# The Prisma client (lib/generated/**) is gitignored and derived from +# prisma/schema.prisma, which is copied verbatim from familiarise_web (which +# owns the database and its migrations). Never hand-edit either. +set -euo pipefail + +cd "$(dirname "$0")/.." + +REGEN_PRISMA=false +for arg in "$@"; do + case "$arg" in + --prisma) REGEN_PRISMA=true ;; + -h|--help) sed -n '2,9p' "$0"; exit 0 ;; + *) echo "Unknown option: $arg" >&2; exit 2 ;; + esac +done + +# prisma_flutter_connector depends on the Flutter SDK, so resolution must go +# through `flutter pub` when Flutter is available (it always is in CI and on +# dev machines); plain `dart pub get` fails with "the Flutter SDK is not +# available". +if command -v flutter >/dev/null 2>&1; then + PUB="flutter pub" +else + PUB="dart pub" +fi + +echo "==> $PUB get" +$PUB get + +if [ "$REGEN_PRISMA" = true ] || [ ! -d lib/generated ]; then + echo "==> Generating Prisma client from prisma/schema.prisma" + rm -rf lib/generated + dart run prisma_flutter_connector:generate \ + --schema prisma/schema.prisma \ + --output lib/generated \ + --server +fi + +echo "==> build_runner (freezed / json_serializable)" +dart run build_runner build --delete-conflicting-outputs + +echo "==> Done." diff --git a/backend/test/helpers/prisma_mocks.dart b/backend/test/helpers/prisma_mocks.dart new file mode 100644 index 0000000..0c466d4 --- /dev/null +++ b/backend/test/helpers/prisma_mocks.dart @@ -0,0 +1,631 @@ +import 'package:backend/generated/index.dart'; +import 'package:mocktail/mocktail.dart'; + +/// Shared mocktail doubles for the generated typed Prisma surface. +/// +/// The JQB→typed-delegate migration moved repositories off the raw +/// [QueryExecutor] and onto [PrismaClient] delegates, so tests now stub +/// delegates instead of executor queries. +class MockPrismaClient extends Mock implements PrismaClient {} + +class MockUserDelegate extends Mock implements UserDelegate {} + +class MockAccountDelegate extends Mock implements AccountDelegate {} + +class MockSessionDelegate extends Mock implements SessionDelegate {} + +class MockVerificationDelegate extends Mock implements VerificationDelegate {} + +class MockConsulteeProfileDelegate extends Mock + implements ConsulteeProfileDelegate {} + +class MockCookiePreferenceDelegate extends Mock + implements CookiePreferenceDelegate {} + +class MockNotificationPreferenceDelegate extends Mock + implements NotificationPreferenceDelegate {} + +class MockSupportTicketDelegate extends Mock implements SupportTicketDelegate {} + +class MockSupportResponseDelegate extends Mock + implements SupportResponseDelegate {} + +class MockSupportTicketAttachmentDelegate extends Mock + implements SupportTicketAttachmentDelegate {} + +// --------------------------------------------------------------------------- +// Fallbacks for `any(named: ...)` on typed inputs. +// --------------------------------------------------------------------------- + +class FakeUserWhereInput extends Fake implements UserWhereInput {} + +class FakeUserWhereUniqueInput extends Fake implements UserWhereUniqueInput {} + +class FakeCreateUserInput extends Fake implements CreateUserInput {} + +class FakeUpdateUserInput extends Fake implements UpdateUserInput {} + +class FakeAccountWhereInput extends Fake implements AccountWhereInput {} + +class FakeAccountWhereUniqueInput extends Fake + implements AccountWhereUniqueInput {} + +class FakeCreateAccountInput extends Fake implements CreateAccountInput {} + +class FakeUpdateAccountInput extends Fake implements UpdateAccountInput {} + +class FakeSessionWhereInput extends Fake implements SessionWhereInput {} + +class FakeCreateSessionInput extends Fake implements CreateSessionInput {} + +class FakeVerificationWhereInput extends Fake + implements VerificationWhereInput {} + +class FakeCreateVerificationInput extends Fake + implements CreateVerificationInput {} + +class FakeSupportTicketWhereInput extends Fake + implements SupportTicketWhereInput {} + +class FakeSupportTicketWhereUniqueInput extends Fake + implements SupportTicketWhereUniqueInput {} + +class FakeCreateSupportTicketInput extends Fake + implements CreateSupportTicketInput {} + +class FakeUpdateSupportTicketInput extends Fake + implements UpdateSupportTicketInput {} + +class FakeSupportTicketOrderByInput extends Fake + implements SupportTicketOrderByInput {} + +class FakeSupportTicketInclude extends Fake implements SupportTicketInclude {} + +class FakeCreateSupportResponseInput extends Fake + implements CreateSupportResponseInput {} + +class FakeCreateSupportTicketAttachmentInput extends Fake + implements CreateSupportTicketAttachmentInput {} + +class FakeCreateConsulteeProfileInput extends Fake + implements CreateConsulteeProfileInput {} + +class FakeCreateCookiePreferenceInput extends Fake + implements CreateCookiePreferenceInput {} + +class FakeCreateNotificationPreferenceInput extends Fake + implements CreateNotificationPreferenceInput {} + +/// Register every typed-input fallback used by the shared stubs. +void registerPrismaFallbacks() { + registerFallbackValue(FakeUserWhereInput()); + registerFallbackValue(FakeUserWhereUniqueInput()); + registerFallbackValue(FakeCreateUserInput()); + registerFallbackValue(FakeUpdateUserInput()); + registerFallbackValue(FakeAccountWhereInput()); + registerFallbackValue(FakeAccountWhereUniqueInput()); + registerFallbackValue(FakeCreateAccountInput()); + registerFallbackValue(FakeUpdateAccountInput()); + registerFallbackValue(FakeSessionWhereInput()); + registerFallbackValue(FakeCreateSessionInput()); + registerFallbackValue(FakeVerificationWhereInput()); + registerFallbackValue(FakeCreateVerificationInput()); + registerFallbackValue(FakeSupportTicketWhereInput()); + registerFallbackValue(FakeSupportTicketWhereUniqueInput()); + registerFallbackValue(FakeCreateSupportTicketInput()); + registerFallbackValue(FakeUpdateSupportTicketInput()); + registerFallbackValue(FakeSupportTicketOrderByInput()); + registerFallbackValue(FakeSupportTicketInclude()); + registerFallbackValue(FakeCreateSupportResponseInput()); + registerFallbackValue(FakeCreateSupportTicketAttachmentInput()); + registerFallbackValue(FakeCreateConsulteeProfileInput()); + registerFallbackValue(FakeCreateCookiePreferenceInput()); + registerFallbackValue(FakeCreateNotificationPreferenceInput()); +} + +/// Build a [User] with the required scalars filled in. +User buildUser({ + String id = 'user-1', + String name = 'Test User', + String email = 'test@example.com', + UserRole role = UserRole.consultee, + bool emailVerified = false, + bool onboardingCompleted = false, + String? image, + String? consulteeProfileId, + String? consultantProfileId, + String? phone, + String? city, + String? country, +}) { + final now = DateTime.utc(2026, 1, 1); + return User( + id: id, + name: name, + email: email, + role: role, + emailVerified: emailVerified, + onboardingCompleted: onboardingCompleted, + image: image, + consulteeProfileId: consulteeProfileId, + consultantProfileId: consultantProfileId, + phone: phone, + city: city, + country: country, + createdAt: now, + updatedAt: now, + ); +} + +/// Build a [ConsulteeProfile] with the required scalars filled in. +ConsulteeProfile buildConsulteeProfile({ + String id = 'consultee-profile-1', + String userId = 'user-1', +}) { + final now = DateTime.utc(2026, 1, 1); + return ConsulteeProfile( + id: id, + userId: userId, + createdAt: now, + updatedAt: now, + ); +} + +/// Make `client.$transaction(cb)` run [cb] against [client] itself, so the +/// same delegate stubs serve both transactional and non-transactional calls. +void stubTransaction(MockPrismaClient client) { + when(() => client.$transaction(any())).thenAnswer((invocation) async { + final callback = invocation.positionalArguments.first as Future Function( + PrismaClient); + return callback(client); + }); +} + +/// Build an [Account] with the required scalars filled in. +Account buildAccount({ + String id = 'account-1', + String userId = 'user-1', + String accountId = 'account-1', + String providerId = 'credential', +}) { + final now = DateTime.utc(2026, 1, 1); + return Account( + id: id, + userId: userId, + accountId: accountId, + providerId: providerId, + createdAt: now, + updatedAt: now, + ); +} + +/// Build a [CookiePreference] with the required scalars filled in. +CookiePreference buildCookiePreference({String id = 'cookie-pref-1'}) { + final now = DateTime.utc(2026, 1, 1); + return CookiePreference( + id: id, + consentGivenAt: now, + consentUpdatedAt: now, + ); +} + +/// Build a [NotificationPreference] with the required scalars filled in. +NotificationPreference buildNotificationPreference({ + String id = 'notif-pref-1', + String userId = 'user-1', +}) { + return NotificationPreference(id: id, userId: userId); +} + +/// Build a [Verification] with the required scalars filled in. +Verification buildVerification({ + String id = 'verification-1', + String identifier = 'password-reset:test@example.com', + String value = 'token123', + DateTime? expiresAt, +}) { + final now = DateTime.utc(2026, 1, 1); + return Verification( + id: id, + identifier: identifier, + value: value, + expiresAt: expiresAt ?? now.add(const Duration(hours: 1)), + createdAt: now, + updatedAt: now, + ); +} + +/// Build a [Session] with the required scalars filled in. +Session buildSession({ + String id = 'session-1', + String token = 'session-token', + String userId = 'user-1', + DateTime? expiresAt, +}) { + final now = DateTime.utc(2026, 1, 1); + return Session( + id: id, + token: token, + userId: userId, + expiresAt: expiresAt ?? now.add(const Duration(days: 7)), + createdAt: now, + updatedAt: now, + ); +} + +/// Build a [SupportTicket] with the required scalars filled in. +SupportTicket buildSupportTicket({ + String id = 'ticket-1', + String title = 'Test ticket', + String description = 'Something is broken', + String userId = 'user-1', + SupportTicketStatus status = SupportTicketStatus.open, + SupportPriority priority = SupportPriority.medium, + SupportIssueType? issueType, +}) { + final now = DateTime.utc(2026, 1, 1); + return SupportTicket( + id: id, + title: title, + description: description, + userId: userId, + status: status, + priority: priority, + issueType: issueType, + createdAt: now, + updatedAt: now, + ); +} + +/// Build a [SupportResponse] with the required scalars filled in. +SupportResponse buildSupportResponse({ + String id = 'response-1', + String message = 'We are on it', + String supportTicketId = 'ticket-1', + String userId = 'user-1', +}) { + final now = DateTime.utc(2026, 1, 1); + return SupportResponse( + id: id, + message: message, + supportTicketId: supportTicketId, + userId: userId, + createdAt: now, + updatedAt: now, + ); +} + +/// Build a [SupportTicketAttachment] with the required scalars filled in. +SupportTicketAttachment buildSupportTicketAttachment({ + String id = 'attachment-1', + String fileName = 'screenshot.png', + String ticketId = 'ticket-1', +}) { + return SupportTicketAttachment( + id: id, + fileName: fileName, + originalName: fileName, + fileSize: 1024, + mimeType: 'image/png', + fileUrl: 'https://example.test/$fileName', + storagePath: 'tickets/$ticketId/$fileName', + ticketId: ticketId, + uploadedAt: DateTime.utc(2026, 1, 1), + ); +} + +class MockConsultantProfileDelegate extends Mock + implements ConsultantProfileDelegate {} + +class MockConsultantReviewDelegate extends Mock + implements ConsultantReviewDelegate {} + +class MockConsultationPlanDelegate extends Mock + implements ConsultationPlanDelegate {} + +class MockSubscriptionPlanDelegate extends Mock + implements SubscriptionPlanDelegate {} + +class FakeConsultantProfileWhereInput extends Fake + implements ConsultantProfileWhereInput {} + +class FakeConsultantReviewWhereInput extends Fake + implements ConsultantReviewWhereInput {} + +class FakeConsultationPlanWhereInput extends Fake + implements ConsultationPlanWhereInput {} + +class FakeSubscriptionPlanWhereInput extends Fake + implements SubscriptionPlanWhereInput {} + +class FakeConsulteeProfileWhereInput extends Fake + implements ConsulteeProfileWhereInput {} + +/// Register fallbacks for the explore/profile typed inputs. +void registerExploreFallbacks() { + registerFallbackValue(FakeConsultantProfileWhereInput()); + registerFallbackValue(FakeConsultantReviewWhereInput()); + registerFallbackValue(FakeConsultationPlanWhereInput()); + registerFallbackValue(FakeSubscriptionPlanWhereInput()); + registerFallbackValue(FakeConsulteeProfileWhereInput()); +} + +class MockConsultationDelegate extends Mock implements ConsultationDelegate {} + +class MockSubscriptionDelegate extends Mock implements SubscriptionDelegate {} + +class MockAppointmentDelegate extends Mock implements AppointmentDelegate {} + +class MockSlotOfAppointmentDelegate extends Mock + implements SlotOfAppointmentDelegate {} + +class MockPaymentDelegate extends Mock implements PaymentDelegate {} + +class MockDiscountCodeDelegate extends Mock implements DiscountCodeDelegate {} + +class FakeConsultationWhereInput extends Fake + implements ConsultationWhereInput {} + +class FakeSubscriptionWhereInput extends Fake + implements SubscriptionWhereInput {} + +class FakeAppointmentWhereInput extends Fake implements AppointmentWhereInput {} + +class FakeSlotOfAppointmentWhereInput extends Fake + implements SlotOfAppointmentWhereInput {} + +class FakePaymentWhereInput extends Fake implements PaymentWhereInput {} + +class FakeDiscountCodeWhereInput extends Fake + implements DiscountCodeWhereInput {} + +class FakePaymentWhereUniqueInput extends Fake + implements PaymentWhereUniqueInput {} + +class FakeCreatePaymentInput extends Fake implements CreatePaymentInput {} + +class FakeUpdatePaymentInput extends Fake implements UpdatePaymentInput {} + +class FakeConsultationWhereUniqueInput extends Fake + implements ConsultationWhereUniqueInput {} + +class FakeUpdateConsultationInput extends Fake + implements UpdateConsultationInput {} + +class FakeSubscriptionWhereUniqueInput extends Fake + implements SubscriptionWhereUniqueInput {} + +class FakeUpdateSubscriptionInput extends Fake + implements UpdateSubscriptionInput {} + +class FakeConsultationPlanWhereUniqueInput extends Fake + implements ConsultationPlanWhereUniqueInput {} + +class FakeSubscriptionPlanWhereUniqueInput extends Fake + implements SubscriptionPlanWhereUniqueInput {} + +class FakeUpdateSlotOfAppointmentInput extends Fake + implements UpdateSlotOfAppointmentInput {} + +class FakeAppointmentInclude extends Fake implements AppointmentInclude {} + +class FakeAppointmentWhereUniqueInput extends Fake + implements AppointmentWhereUniqueInput {} + +class FakeConsultantProfileWhereUniqueInput extends Fake + implements ConsultantProfileWhereUniqueInput {} + +class FakeConsultantProfileInclude extends Fake + implements ConsultantProfileInclude {} + +class FakeConsultationPlanInclude extends Fake + implements ConsultationPlanInclude {} + +class FakeSubscriptionPlanInclude extends Fake + implements SubscriptionPlanInclude {} + +class FakeConsultationInclude extends Fake implements ConsultationInclude {} + +class FakeSubscriptionInclude extends Fake implements SubscriptionInclude {} + +/// Register fallbacks for the booking/checkout typed inputs. +void registerBookingFallbacks() { + registerFallbackValue(FakeConsultationWhereInput()); + registerFallbackValue(FakeSubscriptionWhereInput()); + registerFallbackValue(FakeAppointmentWhereInput()); + registerFallbackValue(FakeSlotOfAppointmentWhereInput()); + registerFallbackValue(FakePaymentWhereInput()); + registerFallbackValue(FakeDiscountCodeWhereInput()); + registerFallbackValue(FakePaymentWhereUniqueInput()); + registerFallbackValue(FakeCreatePaymentInput()); + registerFallbackValue(FakeUpdatePaymentInput()); + registerFallbackValue(FakeConsultationWhereUniqueInput()); + registerFallbackValue(FakeUpdateConsultationInput()); + registerFallbackValue(FakeSubscriptionWhereUniqueInput()); + registerFallbackValue(FakeUpdateSubscriptionInput()); + registerFallbackValue(FakeConsultationPlanWhereUniqueInput()); + registerFallbackValue(FakeSubscriptionPlanWhereUniqueInput()); + registerFallbackValue(FakeUpdateSlotOfAppointmentInput()); + registerFallbackValue(FakeAppointmentInclude()); + registerFallbackValue(FakeAppointmentWhereUniqueInput()); + registerFallbackValue(FakeConsultantProfileWhereUniqueInput()); + registerFallbackValue(FakeConsultantProfileInclude()); + registerFallbackValue(FakeConsultationPlanInclude()); + registerFallbackValue(FakeSubscriptionPlanInclude()); + registerFallbackValue(FakeConsultationInclude()); + registerFallbackValue(FakeSubscriptionInclude()); +} + +class MockClassModelDelegate extends Mock implements ClassModelDelegate {} + +class MockClassPlanDelegate extends Mock implements ClassPlanDelegate {} + +class MockWebinarDelegate extends Mock implements WebinarDelegate {} + +class MockWebinarPlanDelegate extends Mock implements WebinarPlanDelegate {} + +class MockTrialSessionDelegate extends Mock implements TrialSessionDelegate {} + +class FakeClassModelWhereInput extends Fake implements ClassModelWhereInput {} + +class FakeClassPlanWhereInput extends Fake implements ClassPlanWhereInput {} + +class FakeWebinarWhereInput extends Fake implements WebinarWhereInput {} + +class FakeWebinarPlanWhereInput extends Fake implements WebinarPlanWhereInput {} + +class FakeTrialSessionWhereInput extends Fake + implements TrialSessionWhereInput {} + +/// Register fallbacks for the program (class/webinar/trial) typed inputs. +void registerProgramFallbacks() { + registerFallbackValue(FakeClassModelWhereInput()); + registerFallbackValue(FakeClassPlanWhereInput()); + registerFallbackValue(FakeWebinarWhereInput()); + registerFallbackValue(FakeWebinarPlanWhereInput()); + registerFallbackValue(FakeTrialSessionWhereInput()); +} + +/// Build a [Payment] with the required scalars filled in. +Payment buildPayment({ + String id = 'pay-1', + int amount = 5000, + Currency currency = Currency.inr, + PaymentGateway paymentGateway = PaymentGateway.stripe, + PaymentStatus paymentStatus = PaymentStatus.pending, + String userId = 'user-1', +}) { + final now = DateTime.utc(2026, 1, 1); + return Payment( + id: id, + amount: BigInt.from(amount), + originalAmount: BigInt.from(amount), + taxAmount: BigInt.zero, + paymentMethod: 'CARD', + paymentIntent: 'pi_$id', + paymentGateway: paymentGateway, + paymentStatus: paymentStatus, + currency: currency, + userId: userId, + createdAt: now, + updatedAt: now, + ); +} + +/// Build a [ConsultationPlan] with the required scalars filled in. +ConsultationPlan buildConsultationPlan({ + String id = 'plan-1', + String title = 'Consultation plan', + int price = 5000, + String consultantProfileId = 'consultant-1', +}) { + final now = DateTime.utc(2026, 1, 1); + return ConsultationPlan( + id: id, + title: title, + price: BigInt.from(price), + consultantProfileId: consultantProfileId, + createdAt: now, + updatedAt: now, + ); +} + +/// Build a [SubscriptionPlan] with the required scalars filled in. +SubscriptionPlan buildSubscriptionPlan({ + String id = 'sub-plan-1', + String title = 'Subscription plan', + int price = 20000, + String consultantProfileId = 'consultant-1', +}) { + final now = DateTime.utc(2026, 1, 1); + return SubscriptionPlan( + id: id, + title: title, + price: BigInt.from(price), + trialPriceInPaise: BigInt.zero, + consultantProfileId: consultantProfileId, + createdAt: now, + updatedAt: now, + ); +} + +/// Build a [DiscountCode] with the required scalars filled in. +DiscountCode buildDiscountCode({ + String id = 'disc-1', + String code = 'SAVE10', + DiscountType discountType = DiscountType.percentage, + int discountValue = 10, + DateTime? expiresAt, + int? maxUses, + int currentUses = 0, + BigInt? maxDiscount, +}) { + final now = DateTime.utc(2026, 1, 1); + return DiscountCode( + id: id, + code: code, + description: 'Test discount', + discountType: discountType, + discountValue: discountValue, + expiresAt: expiresAt, + maxUses: maxUses, + currentUses: currentUses, + maxDiscount: maxDiscount, + createdAt: now, + updatedAt: now, + ); +} + +/// Build a [Consultation] with the required scalars filled in. +Consultation buildConsultation({ + String id = 'cons-1', + String consultationPlanId = 'plan-1', + String requestedById = 'consultee-1', + AppointmentStatus status = AppointmentStatus.pending, +}) { + final now = DateTime.utc(2026, 1, 1); + return Consultation( + id: id, + consultationPlanId: consultationPlanId, + requestedById: requestedById, + status: status, + requestedAt: now, + createdAt: now, + updatedAt: now, + ); +} + +/// Build a [Subscription] with the required scalars filled in. +Subscription buildSubscription({ + String id = 'sub-1', + String subscriptionPlanId = 'sub-plan-1', + String requestedById = 'consultee-1', + AppointmentStatus status = AppointmentStatus.pending, +}) { + final now = DateTime.utc(2026, 1, 1); + return Subscription( + id: id, + subscriptionPlanId: subscriptionPlanId, + requestedById: requestedById, + status: status, + schedulingPeriodStartsAt: now, + schedulingPeriodEndsAt: now.add(const Duration(days: 30)), + requestedAt: now, + createdAt: now, + updatedAt: now, + ); +} + +/// Build an [Appointment] with the required scalars filled in. +Appointment buildAppointment({ + String id = 'apt-1', + AppointmentsType appointmentType = AppointmentsType.consultation, +}) { + final now = DateTime.utc(2026, 1, 1); + return Appointment( + id: id, + appointmentType: appointmentType, + createdAt: now, + updatedAt: now, + ); +} diff --git a/backend/test/repositories/account_repository_test.dart b/backend/test/repositories/account_repository_test.dart index a06159f..37cb988 100644 --- a/backend/test/repositories/account_repository_test.dart +++ b/backend/test/repositories/account_repository_test.dart @@ -4,54 +4,54 @@ import 'package:mocktail/mocktail.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; import 'package:test/test.dart'; -class MockQueryExecutor extends Mock implements QueryExecutor {} +import '../helpers/prisma_mocks.dart'; -class MockPrismaClient extends Mock implements PrismaClient {} +class MockQueryExecutor extends Mock implements QueryExecutor {} class FakeJsonQuery extends Fake implements JsonQuery {} void main() { late MockQueryExecutor mockExecutor; + late MockPrismaClient mockPrisma; + late MockAccountDelegate mockAccounts; late AccountRepository repository; setUpAll(() { registerFallbackValue(FakeJsonQuery()); + registerPrismaFallbacks(); }); setUp(() { mockExecutor = MockQueryExecutor(); - repository = AccountRepository(mockExecutor, MockPrismaClient()); + mockPrisma = MockPrismaClient(); + mockAccounts = MockAccountDelegate(); + when(() => mockPrisma.account).thenReturn(mockAccounts); + repository = AccountRepository(mockExecutor, mockPrisma); }); group('findByUserAndProvider', () { test('returns account when found', () async { - final expected = { - 'id': 'acc-1', - 'userId': 'user-1', - 'providerId': 'google', - 'accountId': 'google-123', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); - - final result = await repository.findByUserAndProvider( - 'user-1', - 'google', - ); + when(() => mockAccounts.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildAccount(providerId: 'google')); + + final result = await repository.findByUserAndProvider('user-1', 'google'); + + expect(result?['id'], equals('account-1')); + expect(result?['providerId'], equals('google')); - expect(result, equals(expected)); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + // Assert the filter actually sent, not merely that a call happened. + final where = verify( + () => mockAccounts.findFirst(where: captureAny(named: 'where')), + ).captured.single as AccountWhereInput; + expect(where.userId?.equals, equals('user-1')); + expect(where.providerId?.equals, equals('google')); }); test('returns null when no account found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockAccounts.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => null); - final result = await repository.findByUserAndProvider( - 'user-1', - 'github', - ); + final result = await repository.findByUserAndProvider('user-1', 'github'); expect(result, isNull); }); @@ -59,26 +59,22 @@ void main() { group('findCredentialAccount', () { test('returns credential account for user', () async { - final expected = { - 'id': 'acc-1', - 'userId': 'user-1', - 'providerId': 'credential', - 'accountId': 'user-1', - 'password': 'hashed-pw', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when(() => mockAccounts.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildAccount()); final result = await repository.findCredentialAccount('user-1'); - expect(result, equals(expected)); expect(result?['providerId'], equals('credential')); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + + final where = verify( + () => mockAccounts.findFirst(where: captureAny(named: 'where')), + ).captured.single as AccountWhereInput; + expect(where.userId?.equals, equals('user-1')); + expect(where.providerId?.equals, equals('credential')); }); test('returns null when no credential account exists', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockAccounts.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => null); final result = await repository.findCredentialAccount('user-1'); @@ -89,139 +85,98 @@ void main() { group('updatePassword', () { test('updates password and returns updated account', () async { - final expected = { - 'id': 'acc-1', - 'userId': 'user-1', - 'password': 'new-hashed-pw', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when( + () => mockAccounts.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => 1); + when(() => mockAccounts.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildAccount()); final result = await repository.updatePassword( - accountId: 'acc-1', - hashedPassword: 'new-hashed-pw', + accountId: 'account-1', + hashedPassword: 'new-hash', ); - expect(result?['password'], equals('new-hashed-pw')); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + expect(result?['id'], equals('account-1')); + + final captured = verify( + () => mockAccounts.updateMany( + where: captureAny(named: 'where'), + data: captureAny(named: 'data'), + ), + ).captured; + expect( + (captured[0] as AccountWhereInput).id?.equals, equals('account-1')); + expect((captured[1] as UpdateAccountInput).password, equals('new-hash')); }); test('returns null when account not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); + when( + () => mockAccounts.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => 0); final result = await repository.updatePassword( accountId: 'nonexistent', - hashedPassword: 'new-hashed-pw', + hashedPassword: 'new-hash', ); expect(result, isNull); + verifyNever(() => mockAccounts.findFirst(where: any(named: 'where'))); }); }); group('createOAuth', () { test('creates OAuth account link and returns result', () async { - final expected = { - 'id': 'acc-1', - 'userId': 'user-1', - 'providerId': 'google', - 'accountId': 'google-123', - 'accessToken': 'access-token-xyz', - 'idToken': 'id-token-xyz', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when(() => mockAccounts.create(data: any(named: 'data'))) + .thenAnswer((_) async => buildAccount(providerId: 'google')); final result = await repository.createOAuth( - id: 'acc-1', + id: 'account-1', userId: 'user-1', providerId: 'google', accountId: 'google-123', - accessToken: 'access-token-xyz', - idToken: 'id-token-xyz', + accessToken: 'access-token', + idToken: 'id-token', ); expect(result['providerId'], equals('google')); - expect(result['accessToken'], equals('access-token-xyz')); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); - }); - - test('creates OAuth account without optional tokens', () async { - final expected = { - 'id': 'acc-2', - 'userId': 'user-1', - 'providerId': 'github', - 'accountId': 'github-456', - }; - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); - - final result = await repository.createOAuth( - id: 'acc-2', - userId: 'user-1', - providerId: 'github', - accountId: 'github-456', - ); - - expect(result['providerId'], equals('github')); - }); - - test('throws when database fails to create OAuth account', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); - - expect( - () => repository.createOAuth( - id: 'acc-1', - userId: 'user-1', - providerId: 'google', - accountId: 'google-123', - ), - throwsA(isA()), - ); + final data = verify( + () => mockAccounts.create(data: captureAny(named: 'data')), + ).captured.single as CreateAccountInput; + expect(data.userId, equals('user-1')); + expect(data.providerId, equals('google')); + expect(data.accountId, equals('google-123')); + expect(data.accessToken, equals('access-token')); + expect(data.idToken, equals('id-token')); }); }); group('createCredentials', () { test('creates credential account and returns result', () async { - final expected = { - 'id': 'acc-1', - 'userId': 'user-1', - 'providerId': 'credential', - 'accountId': 'user-1', - 'password': 'hashed-pw-123', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when(() => mockAccounts.create(data: any(named: 'data'))) + .thenAnswer((_) async => buildAccount()); final result = await repository.createCredentials( - id: 'acc-1', + id: 'account-1', userId: 'user-1', - hashedPassword: 'hashed-pw-123', + hashedPassword: 'hashed', ); expect(result['providerId'], equals('credential')); - expect(result['accountId'], equals('user-1')); - expect(result['password'], equals('hashed-pw-123')); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); - }); - - test('throws when database fails to create credentials', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); - - expect( - () => repository.createCredentials( - id: 'acc-1', - userId: 'user-1', - hashedPassword: 'hashed-pw', - ), - throwsA(isA()), - ); + expect(result['userId'], equals('user-1')); + + final data = verify( + () => mockAccounts.create(data: captureAny(named: 'data')), + ).captured.single as CreateAccountInput; + expect(data.userId, equals('user-1')); + expect(data.providerId, equals('credential')); + expect(data.password, equals('hashed')); }); }); } diff --git a/backend/test/repositories/appointment_repository_test.dart b/backend/test/repositories/appointment_repository_test.dart index 5a8ea81..83b36c7 100644 --- a/backend/test/repositories/appointment_repository_test.dart +++ b/backend/test/repositories/appointment_repository_test.dart @@ -1,36 +1,138 @@ +// Mock stubbing spans several generated delegate classes that share no base +// type, so the shared stub helpers take `dynamic` — which costs type +// inference on the mocktail calls. Test-only plumbing, not a correctness gap. +// ignore_for_file: inference_failure_on_function_invocation, strict_raw_type + import 'package:backend/database/repositories/appointment_repository.dart'; import 'package:mocktail/mocktail.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; import 'package:test/test.dart'; +import '../helpers/prisma_mocks.dart'; + class MockQueryExecutor extends Mock implements QueryExecutor {} class FakeJsonQuery extends Fake implements JsonQuery {} void main() { late MockQueryExecutor mockExecutor; + late MockPrismaClient mockPrisma; + late MockConsultationPlanDelegate mockConsultationPlans; + late MockSubscriptionPlanDelegate mockSubscriptionPlans; + late MockConsultationDelegate mockConsultations; + late MockSubscriptionDelegate mockSubscriptions; + late MockAppointmentDelegate mockAppointments; + late MockSlotOfAppointmentDelegate mockSlots; + late MockConsulteeProfileDelegate mockConsulteeProfiles; + late MockConsultantProfileDelegate mockConsultantProfiles; + late MockClassModelDelegate mockClasses; + late MockClassPlanDelegate mockClassPlans; + late MockWebinarDelegate mockWebinars; + late MockWebinarPlanDelegate mockWebinarPlans; + late MockTrialSessionDelegate mockTrialSessions; late AppointmentRepository repository; + /// Stub every projected finder the booking fetchers touch to an empty + /// result, so each test only stubs what it actually asserts on. + void stubEmptyProjections() { + void empty(dynamic delegate) { + when( + () => delegate.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + include: any(named: 'include'), + distinct: any(named: 'distinct'), + ), + ).thenAnswer((_) async => >[]); + } + + empty(mockConsultationPlans); + empty(mockSubscriptionPlans); + empty(mockConsultations); + empty(mockSubscriptions); + empty(mockAppointments); + empty(mockClasses); + empty(mockClassPlans); + empty(mockWebinars); + empty(mockWebinarPlans); + empty(mockTrialSessions); + empty(mockConsultantProfiles); + } + setUpAll(() { registerFallbackValue(FakeJsonQuery()); + registerPrismaFallbacks(); + registerExploreFallbacks(); + registerBookingFallbacks(); + registerProgramFallbacks(); }); setUp(() { mockExecutor = MockQueryExecutor(); - repository = AppointmentRepository(mockExecutor); + mockPrisma = MockPrismaClient(); + mockConsultationPlans = MockConsultationPlanDelegate(); + mockSubscriptionPlans = MockSubscriptionPlanDelegate(); + mockConsultations = MockConsultationDelegate(); + mockSubscriptions = MockSubscriptionDelegate(); + mockAppointments = MockAppointmentDelegate(); + mockSlots = MockSlotOfAppointmentDelegate(); + mockConsulteeProfiles = MockConsulteeProfileDelegate(); + mockConsultantProfiles = MockConsultantProfileDelegate(); + mockClasses = MockClassModelDelegate(); + mockClassPlans = MockClassPlanDelegate(); + mockWebinars = MockWebinarDelegate(); + mockWebinarPlans = MockWebinarPlanDelegate(); + mockTrialSessions = MockTrialSessionDelegate(); + + when(() => mockPrisma.consultationPlan).thenReturn(mockConsultationPlans); + when(() => mockPrisma.subscriptionPlan).thenReturn(mockSubscriptionPlans); + when(() => mockPrisma.consultation).thenReturn(mockConsultations); + when(() => mockPrisma.subscription).thenReturn(mockSubscriptions); + when(() => mockPrisma.appointment).thenReturn(mockAppointments); + when(() => mockPrisma.slotOfAppointment).thenReturn(mockSlots); + when(() => mockPrisma.consulteeProfile).thenReturn(mockConsulteeProfiles); + when(() => mockPrisma.consultantProfile).thenReturn(mockConsultantProfiles); + when(() => mockPrisma.classModel).thenReturn(mockClasses); + when(() => mockPrisma.classPlan).thenReturn(mockClassPlans); + when(() => mockPrisma.webinar).thenReturn(mockWebinars); + when(() => mockPrisma.webinarPlan).thenReturn(mockWebinarPlans); + when(() => mockPrisma.trialSession).thenReturn(mockTrialSessions); + + stubEmptyProjections(); + when(() => mockConsultations.count(where: any(named: 'where'))) + .thenAnswer((_) async => 0); + when(() => mockSubscriptions.count(where: any(named: 'where'))) + .thenAnswer((_) async => 0); + when(() => mockSlots.count(where: any(named: 'where'))) + .thenAnswer((_) async => 0); + + repository = AppointmentRepository(mockExecutor, mockPrisma); }); + /// Stub the consultant's plan lookup (step 1 of the "active booking" checks). + void stubPlans(dynamic delegate, List ids) { + when( + () => delegate.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + include: any(named: 'include'), + distinct: any(named: 'distinct'), + ), + ).thenAnswer((_) async => [ + for (final id in ids) {'id': id} + ]); + } + group('hasActiveConsultationBooking', () { test('returns true when active consultation exists', () async { - // First query: find plans for consultant - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => [ - {'id': 'plan-1'}, - {'id': 'plan-2'}, - ]); - - // Second query: count active consultations - when(() => mockExecutor.executeCount(any())) + stubPlans(mockConsultationPlans, ['plan-1', 'plan-2']); + when(() => mockConsultations.count(where: any(named: 'where'))) .thenAnswer((_) async => 1); final result = await repository.hasActiveConsultationBooking( @@ -42,8 +144,7 @@ void main() { }); test('returns false when no plans exist for consultant', () async { - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + stubPlans(mockConsultationPlans, []); final result = await repository.hasActiveConsultationBooking( consulteeProfileId: 'consultee-1', @@ -52,16 +153,12 @@ void main() { expect(result, isFalse); // Should not attempt count when no plans exist - verifyNever(() => mockExecutor.executeCount(any())); + verifyNever(() => mockConsultations.count(where: any(named: 'where'))); }); test('returns false when no active consultations exist', () async { - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => [ - {'id': 'plan-1'}, - ]); - - when(() => mockExecutor.executeCount(any())) + stubPlans(mockConsultationPlans, ['plan-1']); + when(() => mockConsultations.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); final result = await repository.hasActiveConsultationBooking( @@ -75,12 +172,8 @@ void main() { group('hasActiveSubscriptionBooking', () { test('returns true when active subscription exists', () async { - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => [ - {'id': 'sub-plan-1'}, - ]); - - when(() => mockExecutor.executeCount(any())) + stubPlans(mockSubscriptionPlans, ['sub-plan-1']); + when(() => mockSubscriptions.count(where: any(named: 'where'))) .thenAnswer((_) async => 1); final result = await repository.hasActiveSubscriptionBooking( @@ -92,8 +185,7 @@ void main() { }); test('returns false when no subscription plans exist', () async { - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + stubPlans(mockSubscriptionPlans, []); final result = await repository.hasActiveSubscriptionBooking( consulteeProfileId: 'consultee-1', @@ -101,15 +193,12 @@ void main() { ); expect(result, isFalse); + verifyNever(() => mockSubscriptions.count(where: any(named: 'where'))); }); test('returns false when no active subscriptions exist', () async { - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => [ - {'id': 'sub-plan-1'}, - ]); - - when(() => mockExecutor.executeCount(any())) + stubPlans(mockSubscriptionPlans, ['sub-plan-1']); + when(() => mockSubscriptions.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); final result = await repository.hasActiveSubscriptionBooking( @@ -123,11 +212,7 @@ void main() { group('checkSlotConflicts', () { test('returns empty list when no existing appointments', () async { - // _getConsultantAppointmentIds queries multiple models - // and returns empty when no plans exist - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); - + // _getConsultantAppointmentIds finds no plans -> no appointment ids. final conflicts = await repository.checkSlotConflicts( consultantProfileId: 'consultant-1', slotStartTimes: [DateTime(2025, 6, 15, 10, 0)], @@ -135,40 +220,15 @@ void main() { ); expect(conflicts, isEmpty); + // No appointments -> never reaches the overlap count. + verifyNever(() => mockSlots.count(where: any(named: 'where'))); }); test('returns conflicting slots when conflicts exist', () async { - // Multiple calls to executeQueryAsMaps for _getConsultantAppointmentIds - var queryMapCallCount = 0; - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async { - queryMapCallCount++; - switch (queryMapCallCount) { - case 1: - // ConsultationPlan IDs - return [ - {'id': 'plan-1'} - ]; - case 2: - // SubscriptionPlan IDs - return []; - case 3: - // Consultation IDs for plans - return [ - {'id': 'cons-1'} - ]; - case 4: - // Appointment IDs for consultations - return [ - {'id': 'apt-1'} - ]; - default: - return []; - } - }); - - // Slot conflict count check returns > 0 - when(() => mockExecutor.executeCount(any())) + stubPlans(mockConsultationPlans, ['plan-1']); + stubPlans(mockConsultations, ['cons-1']); + stubPlans(mockAppointments, ['apt-1']); + when(() => mockSlots.count(where: any(named: 'where'))) .thenAnswer((_) async => 1); final slotStart = DateTime(2025, 6, 15, 10, 0); @@ -183,31 +243,10 @@ void main() { }); test('returns empty when no conflicts found', () async { - var queryMapCallCount = 0; - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async { - queryMapCallCount++; - switch (queryMapCallCount) { - case 1: - return [ - {'id': 'plan-1'} - ]; - case 2: - return []; - case 3: - return [ - {'id': 'cons-1'} - ]; - case 4: - return [ - {'id': 'apt-1'} - ]; - default: - return []; - } - }); - - when(() => mockExecutor.executeCount(any())) + stubPlans(mockConsultationPlans, ['plan-1']); + stubPlans(mockConsultations, ['cons-1']); + stubPlans(mockAppointments, ['apt-1']); + when(() => mockSlots.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); final conflicts = await repository.checkSlotConflicts( @@ -222,16 +261,12 @@ void main() { group('getMyBookings', () { test('returns empty bookings when consultee profile not found', () async { - // Profile query returns null - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); - - // The webinar/class booking fetches use executeQueryAsMaps - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); - - when(() => mockExecutor.executeCount(any())) - .thenAnswer((_) async => 0); + when( + () => mockConsulteeProfiles.findFirstProjected( + where: any(named: 'where'), + select: any(named: 'select'), + ), + ).thenAnswer((_) async => null); final result = await repository.getMyBookings(userId: 'user-1'); @@ -240,19 +275,12 @@ void main() { }); test('returns bookings sorted by createdAt descending', () async { - // ConsulteeProfile - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => { - 'id': 'cp-1', - 'userId': 'user-1', - }); - - // All booking queries return empty for simplicity - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); - - when(() => mockExecutor.executeCount(any())) - .thenAnswer((_) async => 0); + when( + () => mockConsulteeProfiles.findFirstProjected( + where: any(named: 'where'), + select: any(named: 'select'), + ), + ).thenAnswer((_) async => {'id': 'cp-1', 'userId': 'user-1'}); final result = await repository.getMyBookings(userId: 'user-1'); @@ -261,19 +289,14 @@ void main() { }); test('fetches consultant bookings when asConsultant is true', () async { - // ConsultantProfile - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => { - 'id': 'consultant-profile-1', - 'userId': 'user-1', - }); - - // All booking queries return empty - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); - - when(() => mockExecutor.executeCount(any())) - .thenAnswer((_) async => 0); + when( + () => mockConsultantProfiles.findFirstProjected( + where: any(named: 'where'), + select: any(named: 'select'), + ), + ).thenAnswer( + (_) async => {'id': 'consultant-profile-1', 'userId': 'user-1'}, + ); final result = await repository.getMyBookings( userId: 'user-1', @@ -284,14 +307,12 @@ void main() { }); test('returns empty when consultant profile not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); - - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); - - when(() => mockExecutor.executeCount(any())) - .thenAnswer((_) async => 0); + when( + () => mockConsultantProfiles.findFirstProjected( + where: any(named: 'where'), + select: any(named: 'select'), + ), + ).thenAnswer((_) async => null); final result = await repository.getMyBookings( userId: 'user-1', @@ -305,14 +326,8 @@ void main() { group('createConsultationBooking', () { test('throws DuplicateBookingException when active booking exists', () async { - // hasActiveConsultationBooking: plans query returns plans - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => [ - {'id': 'plan-1'}, - ]); - - // Count active consultations > 0 - when(() => mockExecutor.executeCount(any())) + stubPlans(mockConsultationPlans, ['plan-1']); + when(() => mockConsultations.count(where: any(named: 'where'))) .thenAnswer((_) async => 1); expect( @@ -331,14 +346,8 @@ void main() { group('createSubscriptionBooking', () { test('throws DuplicateBookingException when active subscription exists', () async { - // hasActiveSubscriptionBooking: plans query returns plans - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => [ - {'id': 'sub-plan-1'}, - ]); - - // Count active subscriptions > 0 - when(() => mockExecutor.executeCount(any())) + stubPlans(mockSubscriptionPlans, ['sub-plan-1']); + when(() => mockSubscriptions.count(where: any(named: 'where'))) .thenAnswer((_) async => 1); expect( @@ -346,7 +355,7 @@ void main() { consultantProfileId: 'consultant-1', planId: 'sub-plan-1', requestedById: 'consultee-1', - schedulingPeriodStart: DateTime(2025, 7, 1), + schedulingPeriodStart: DateTime(2025, 6, 15, 10, 0), ), throwsA(isA()), ); diff --git a/backend/test/repositories/checkout_repository_test.dart b/backend/test/repositories/checkout_repository_test.dart index f19d2d8..969c0ec 100644 --- a/backend/test/repositories/checkout_repository_test.dart +++ b/backend/test/repositories/checkout_repository_test.dart @@ -1,29 +1,61 @@ import 'package:backend/database/repositories/checkout_repository.dart'; +import 'package:backend/generated/index.dart'; import 'package:mocktail/mocktail.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; import 'package:test/test.dart'; +import '../helpers/prisma_mocks.dart'; + class MockQueryExecutor extends Mock implements QueryExecutor {} class FakeJsonQuery extends Fake implements JsonQuery {} void main() { late MockQueryExecutor mockExecutor; + late MockPrismaClient mockPrisma; + late MockPaymentDelegate mockPayments; + late MockConsultationPlanDelegate mockConsultationPlans; + late MockSubscriptionPlanDelegate mockSubscriptionPlans; + late MockConsultationDelegate mockConsultations; + late MockSubscriptionDelegate mockSubscriptions; + late MockDiscountCodeDelegate mockDiscountCodes; + late MockAppointmentDelegate mockAppointments; + late MockSlotOfAppointmentDelegate mockSlots; late CheckoutRepository repository; setUpAll(() { registerFallbackValue(FakeJsonQuery()); + registerPrismaFallbacks(); + registerExploreFallbacks(); + registerBookingFallbacks(); }); setUp(() { mockExecutor = MockQueryExecutor(); - repository = CheckoutRepository(mockExecutor); + mockPrisma = MockPrismaClient(); + mockPayments = MockPaymentDelegate(); + mockConsultationPlans = MockConsultationPlanDelegate(); + mockSubscriptionPlans = MockSubscriptionPlanDelegate(); + mockConsultations = MockConsultationDelegate(); + mockSubscriptions = MockSubscriptionDelegate(); + mockDiscountCodes = MockDiscountCodeDelegate(); + mockAppointments = MockAppointmentDelegate(); + mockSlots = MockSlotOfAppointmentDelegate(); + when(() => mockPrisma.payment).thenReturn(mockPayments); + when(() => mockPrisma.consultationPlan).thenReturn(mockConsultationPlans); + when(() => mockPrisma.subscriptionPlan).thenReturn(mockSubscriptionPlans); + when(() => mockPrisma.consultation).thenReturn(mockConsultations); + when(() => mockPrisma.subscription).thenReturn(mockSubscriptions); + when(() => mockPrisma.discountCode).thenReturn(mockDiscountCodes); + when(() => mockPrisma.appointment).thenReturn(mockAppointments); + when(() => mockPrisma.slotOfAppointment).thenReturn(mockSlots); + repository = CheckoutRepository(mockExecutor, mockPrisma); }); group('createPayment', () { test('creates payment and returns payment details', () async { - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); + when(() => mockPayments.create(data: any(named: 'data'))) + .thenAnswer((_) async => buildPayment()); final result = await repository.createPayment( userId: 'user-1', @@ -37,12 +69,12 @@ void main() { expect(result['amount'], equals(5000)); expect(result['currency'], equals('INR')); expect(result['gateway'], equals('STRIPE')); - verify(() => mockExecutor.executeMutation(any())).called(1); + verify(() => mockPayments.create(data: any(named: 'data'))).called(1); }); test('creates payment with optional appointment ID', () async { - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); + when(() => mockPayments.create(data: any(named: 'data'))) + .thenAnswer((_) async => buildPayment()); final result = await repository.createPayment( userId: 'user-1', @@ -57,8 +89,8 @@ void main() { }); test('creates payment with discount code', () async { - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); + when(() => mockPayments.create(data: any(named: 'data'))) + .thenAnswer((_) async => buildPayment()); final result = await repository.createPayment( userId: 'user-1', @@ -76,25 +108,21 @@ void main() { group('getPaymentById', () { test('returns payment when found', () async { - final expected = { - 'id': 'pay-1', - 'amount': 5000, - 'currency': 'INR', - 'paymentStatus': 'PENDING', - 'userId': 'user-1', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when(() => mockPayments.findUnique(where: any(named: 'where'))) + .thenAnswer((_) async => buildPayment()); final result = await repository.getPaymentById('pay-1'); - expect(result, equals(expected)); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + expect(result?['id'], equals('pay-1')); + expect(result?['currency'], equals('INR')); + expect(result?['paymentStatus'], equals('PENDING')); + expect(result?['userId'], equals('user-1')); + verify(() => mockPayments.findUnique(where: any(named: 'where'))) + .called(1); }); test('returns null when payment not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findUnique(where: any(named: 'where'))) .thenAnswer((_) async => null); final result = await repository.getPaymentById('nonexistent'); @@ -105,67 +133,85 @@ void main() { group('updatePaymentStatus', () { test('updates payment status successfully', () async { - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); + when( + () => mockPayments.update( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => buildPayment()); await expectLater( repository.updatePaymentStatus( paymentId: 'pay-1', - status: 'COMPLETED', + status: 'SUCCEEDED', ), completes, ); - verify(() => mockExecutor.executeMutation(any())).called(1); + verify( + () => mockPayments.update( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).called(1); }); test('updates payment status with receipt URL', () async { - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); + when( + () => mockPayments.update( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => buildPayment()); await expectLater( repository.updatePaymentStatus( paymentId: 'pay-1', - status: 'COMPLETED', + status: 'SUCCEEDED', receiptUrl: 'https://receipt.example.com/123', ), completes, ); - verify(() => mockExecutor.executeMutation(any())).called(1); + verify( + () => mockPayments.update( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).called(1); }); }); group('getConsultationPlan', () { test('returns consultation plan with consultant profile', () async { - final expected = { - 'id': 'plan-1', - 'title': 'Basic Consultation', - 'price': 5000, - 'priceCurrency': 'INR', - 'durationInHours': 1.0, - 'consultantProfile': { - 'id': 'cp-1', - 'user': { - 'name': 'Dr. Test', - 'email': 'dr@example.com', - }, - }, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when( + () => mockConsultationPlans.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer( + (_) async => buildConsultationPlan(title: 'Basic Consultation'), + ); final result = await repository.getConsultationPlan('plan-1'); - expect(result, equals(expected)); - expect(result?['consultantProfile'], isA()); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + expect(result?['id'], equals('plan-1')); + expect(result?['title'], equals('Basic Consultation')); + verify( + () => mockConsultationPlans.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).called(1); }); test('returns null when plan not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); + when( + () => mockConsultationPlans.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); final result = await repository.getConsultationPlan('nonexistent'); @@ -175,30 +221,33 @@ void main() { group('getSubscriptionPlan', () { test('returns subscription plan with details', () async { - final expected = { - 'id': 'sub-plan-1', - 'title': 'Monthly Mentorship', - 'price': 15000, - 'priceCurrency': 'INR', - 'durationInMonths': 3, - 'consultantProfile': { - 'id': 'cp-1', - 'user': {'name': 'Mentor Test'}, - }, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when( + () => mockSubscriptionPlans.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer( + (_) async => buildSubscriptionPlan(title: 'Monthly Mentorship'), + ); final result = await repository.getSubscriptionPlan('sub-plan-1'); expect(result?['title'], equals('Monthly Mentorship')); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + verify( + () => mockSubscriptionPlans.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).called(1); }); test('returns null when subscription plan not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); + when( + () => mockSubscriptionPlans.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); final result = await repository.getSubscriptionPlan('nonexistent'); @@ -208,53 +257,60 @@ void main() { group('getBookingById', () { test('returns consultation booking', () async { - final expected = { - 'id': 'booking-1', - 'requestStatus': 'PENDING', - 'consultationPlan': { - 'consultantProfile': { - 'user': {'name': 'Dr. Test'}, - }, - }, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when( + () => mockConsultations.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => buildConsultation(id: 'booking-1')); final result = await repository.getBookingById( 'booking-1', 'CONSULTATION', ); - expect(result, equals(expected)); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + expect(result?['id'], equals('booking-1')); + expect(result?['requestStatus'], equals('PENDING')); + verify( + () => mockConsultations.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).called(1); }); test('returns subscription booking', () async { - final expected = { - 'id': 'sub-1', - 'requestStatus': 'APPROVED', - 'subscriptionPlan': { - 'consultantProfile': { - 'user': {'name': 'Mentor Test'}, - }, - }, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when( + () => mockSubscriptions.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer( + (_) async => buildSubscription( + id: 'sub-1', + status: AppointmentStatus.approvedPendingPayment, + ), + ); final result = await repository.getBookingById( 'sub-1', 'subscription', ); - expect(result, equals(expected)); + expect(result?['id'], equals('sub-1')); + expect( + result?['requestStatus'], + equals('APPROVED_PENDING_PAYMENT'), + ); }); test('returns null when booking not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); + when( + () => mockConsultations.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); final result = await repository.getBookingById( 'nonexistent', @@ -267,17 +323,15 @@ void main() { group('validateDiscountCode', () { test('returns valid percentage discount', () async { - final discount = { - 'id': 'disc-1', - 'code': 'SAVE20', - 'discountType': 'PERCENTAGE', - 'discountValue': 20.0, - 'maxUses': 100, - 'currentUses': 5, - 'maxDiscount': null, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) + final discount = buildDiscountCode( + id: 'disc-1', + code: 'SAVE20', + discountValue: 20, + maxUses: 100, + currentUses: 5, + ); + + when(() => mockDiscountCodes.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => discount); final result = await repository.validateDiscountCode( @@ -292,17 +346,14 @@ void main() { }); test('returns valid fixed discount', () async { - final discount = { - 'id': 'disc-2', - 'code': 'FLAT500', - 'discountType': 'FIXED', - 'discountValue': 500.0, - 'maxUses': null, - 'currentUses': 0, - 'maxDiscount': null, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) + final discount = buildDiscountCode( + id: 'disc-2', + code: 'FLAT500', + discountType: DiscountType.fixedAmount, + discountValue: 500, + ); + + when(() => mockDiscountCodes.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => discount); final result = await repository.validateDiscountCode( @@ -315,17 +366,14 @@ void main() { }); test('caps percentage discount at maxDiscount', () async { - final discount = { - 'id': 'disc-3', - 'code': 'BIG50', - 'discountType': 'PERCENTAGE', - 'discountValue': 50.0, - 'maxUses': null, - 'currentUses': 0, - 'maxDiscount': 1000.0, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) + final discount = buildDiscountCode( + id: 'disc-3', + code: 'BIG50', + discountValue: 50, + maxDiscount: BigInt.from(1000), + ); + + when(() => mockDiscountCodes.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => discount); final result = await repository.validateDiscountCode( @@ -337,7 +385,7 @@ void main() { }); test('returns invalid result when discount code not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockDiscountCodes.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => null); final result = await repository.validateDiscountCode( @@ -349,16 +397,14 @@ void main() { }); test('returns invalid result when discount code exhausted', () async { - final discount = { - 'id': 'disc-4', - 'code': 'LIMITED', - 'discountType': 'PERCENTAGE', - 'discountValue': 10.0, - 'maxUses': 5, - 'currentUses': 5, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) + final discount = buildDiscountCode( + id: 'disc-4', + code: 'LIMITED', + maxUses: 5, + currentUses: 5, + ); + + when(() => mockDiscountCodes.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => discount); final result = await repository.validateDiscountCode( @@ -371,17 +417,13 @@ void main() { }); test('returns invalid result when discount code expired', () async { - final discount = { - 'id': 'disc-5', - 'code': 'EXPIRED', - 'discountType': 'PERCENTAGE', - 'discountValue': 10.0, - 'expiresAt': DateTime.utc(2020), - 'maxUses': null, - 'currentUses': 0, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) + final discount = buildDiscountCode( + id: 'disc-5', + code: 'EXPIRED', + expiresAt: DateTime.utc(2020), + ); + + when(() => mockDiscountCodes.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => discount); final result = await repository.validateDiscountCode( @@ -396,8 +438,12 @@ void main() { group('updateBookingStatus', () { test('updates consultation booking status', () async { - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); + when( + () => mockConsultations.update( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => buildConsultation()); await expectLater( repository.updateBookingStatus( @@ -408,12 +454,21 @@ void main() { completes, ); - verify(() => mockExecutor.executeMutation(any())).called(1); + verify( + () => mockConsultations.update( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).called(1); }); test('updates subscription booking status', () async { - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); + when( + () => mockSubscriptions.update( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => buildSubscription()); await expectLater( repository.updateBookingStatus( @@ -424,35 +479,59 @@ void main() { completes, ); - verify(() => mockExecutor.executeMutation(any())).called(1); + verify( + () => mockSubscriptions.update( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).called(1); }); }); group('confirmSlots', () { test('confirms slots for a consultation', () async { // First call: find appointment - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => { - 'id': 'apt-1', - 'consultationId': 'cons-1', - }); + when( + () => mockAppointments.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => buildAppointment()); // Second call: update slots - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 3); + when( + () => mockSlots.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => 3); await expectLater( repository.confirmSlots('cons-1'), completes, ); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); - verify(() => mockExecutor.executeMutation(any())).called(1); + verify( + () => mockAppointments.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).called(1); + verify( + () => mockSlots.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).called(1); }); test('completes without error when no appointment found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); + when( + () => mockAppointments.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); await expectLater( repository.confirmSlots('nonexistent'), diff --git a/backend/test/repositories/consultant_explore_repository_test.dart b/backend/test/repositories/consultant_explore_repository_test.dart index cd27498..f9f1179 100644 --- a/backend/test/repositories/consultant_explore_repository_test.dart +++ b/backend/test/repositories/consultant_explore_repository_test.dart @@ -1,73 +1,162 @@ +// Mock stubbing spans several generated delegate classes that share no base +// type, so the shared stub helpers take `dynamic` — which costs type +// inference on the mocktail calls. Test-only plumbing, not a correctness gap. +// ignore_for_file: inference_failure_on_function_invocation, strict_raw_type + import 'package:backend/database/repositories/consultant_explore_repository.dart'; import 'package:mocktail/mocktail.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; import 'package:test/test.dart'; +import '../helpers/prisma_mocks.dart'; + class MockQueryExecutor extends Mock implements QueryExecutor {} class FakeJsonQuery extends Fake implements JsonQuery {} void main() { late MockQueryExecutor mockExecutor; + late MockPrismaClient mockPrisma; + late MockConsultantProfileDelegate mockProfiles; + late MockConsultantReviewDelegate mockReviews; + late MockConsultationPlanDelegate mockConsultationPlans; + late MockSubscriptionPlanDelegate mockSubscriptionPlans; + late MockConsulteeProfileDelegate mockConsulteeProfiles; + late MockUserDelegate mockUsers; late ConsultantExploreRepository repository; setUpAll(() { registerFallbackValue(FakeJsonQuery()); + registerPrismaFallbacks(); + registerExploreFallbacks(); }); setUp(() { mockExecutor = MockQueryExecutor(); - repository = ConsultantExploreRepository(mockExecutor); + mockPrisma = MockPrismaClient(); + mockProfiles = MockConsultantProfileDelegate(); + mockReviews = MockConsultantReviewDelegate(); + mockConsultationPlans = MockConsultationPlanDelegate(); + mockSubscriptionPlans = MockSubscriptionPlanDelegate(); + mockConsulteeProfiles = MockConsulteeProfileDelegate(); + mockUsers = MockUserDelegate(); + when(() => mockPrisma.consultantProfile).thenReturn(mockProfiles); + when(() => mockPrisma.consultantReview).thenReturn(mockReviews); + when(() => mockPrisma.consultationPlan).thenReturn(mockConsultationPlans); + when(() => mockPrisma.subscriptionPlan).thenReturn(mockSubscriptionPlans); + when(() => mockPrisma.consulteeProfile).thenReturn(mockConsulteeProfiles); + when(() => mockPrisma.user).thenReturn(mockUsers); + + // Default: empty projections for the secondary lookups so each test only + // stubs what it asserts on. + when( + () => mockConsultationPlans.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + ), + ).thenAnswer((_) async => []); + when( + () => mockSubscriptionPlans.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + ), + ).thenAnswer((_) async => []); + when( + () => mockUsers.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + ), + ).thenAnswer((_) async => []); + when(() => mockReviews.count(where: any(named: 'where'))) + .thenAnswer((_) async => 0); + when( + () => mockConsulteeProfiles.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + ), + ).thenAnswer((_) async => []); + when( + () => mockProfiles.findFirstProjected( + where: any(named: 'where'), + select: any(named: 'select'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); + when( + () => mockReviews.aggregate( + where: any(named: 'where'), + count: any(named: 'count'), + avg: any(named: 'avg'), + countFiltered: any(named: 'countFiltered'), + ), + ).thenAnswer((_) async => {'_count': 0}); + + repository = ConsultantExploreRepository(mockExecutor, mockPrisma); }); group('findMany', () { test('returns consultants with pagination', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockProfiles.count(where: any(named: 'where'))) .thenAnswer((_) async => 2); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => [ - { - 'id': 'cp-1', - 'userId': 'user-1', - 'headline': 'Expert in Flutter', - 'description': 'Senior developer', - 'rating': 4.5, - 'experience': 5, - 'languages': '["English","Hindi"]', - 'toolsAndTechnologies': '["Flutter","Dart"]', - 'totalMenteesHelped': 50, - 'isVerified': true, - 'domainId': 'dom-1', - 'createdAt': '2025-01-01T00:00:00.000Z', - 'user': {'name': 'John Doe', 'image': null}, - 'domain': {'id': 'dom-1', 'name': 'Technology'}, - 'minPrice': 3000, - 'priceCurrency': 'INR', - 'subDomains': [ - {'id': 'sd-1', 'name': 'Mobile Dev', 'domainId': 'dom-1'} - ], - }, - { - 'id': 'cp-2', - 'userId': 'user-2', - 'headline': 'Design Expert', - 'description': 'UI/UX specialist', - 'rating': 4.8, - 'experience': 8, - 'languages': '["English"]', - 'toolsAndTechnologies': '["Figma"]', - 'totalMenteesHelped': 30, - 'isVerified': true, - 'domainId': 'dom-2', - 'createdAt': '2025-02-01T00:00:00.000Z', - 'user': {'name': 'Jane Smith', 'image': 'img.jpg'}, - 'domain': {'id': 'dom-2', 'name': 'Design'}, - 'minPrice': 5000, - 'priceCurrency': 'INR', - 'subDomains': [], - }, - ]); + when( + () => mockProfiles.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + computed: any(named: 'computed'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => [ + { + 'id': 'cp-1', + 'userId': 'user-1', + 'headline': 'Expert in Flutter', + 'description': 'Senior developer', + 'rating': 4.5, + 'experience': 5, + 'languages': '["English","Hindi"]', + 'toolsAndTechnologies': '["Flutter","Dart"]', + 'totalMenteesHelped': 50, + 'isVerified': true, + 'domainId': 'dom-1', + 'createdAt': '2025-01-01T00:00:00.000Z', + 'user': {'name': 'John Doe', 'image': null}, + 'domain': {'id': 'dom-1', 'name': 'Technology'}, + 'minPrice': 3000, + 'priceCurrency': 'INR', + 'subDomains': [ + {'id': 'sd-1', 'name': 'Mobile Dev', 'domainId': 'dom-1'} + ], + }, + { + 'id': 'cp-2', + 'userId': 'user-2', + 'headline': 'Design Expert', + 'description': 'UI/UX specialist', + 'rating': 4.8, + 'experience': 8, + 'languages': '["English"]', + 'toolsAndTechnologies': '["Figma"]', + 'totalMenteesHelped': 30, + 'isVerified': true, + 'domainId': 'dom-2', + 'createdAt': '2025-02-01T00:00:00.000Z', + 'user': {'name': 'Jane Smith', 'image': 'img.jpg'}, + 'domain': {'id': 'dom-2', 'name': 'Design'}, + 'minPrice': 5000, + 'priceCurrency': 'INR', + 'subDomains': >[], + }, + ]); final result = await repository.findMany(); @@ -85,11 +174,20 @@ void main() { }); test('returns empty list when no consultants found', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockProfiles.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockProfiles.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + computed: any(named: 'computed'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => []); final result = await repository.findMany(); @@ -99,11 +197,20 @@ void main() { }); test('clamps page size to maximum 50', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockProfiles.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockProfiles.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + computed: any(named: 'computed'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => []); final result = await repository.findMany(pageSize: 200); @@ -111,11 +218,20 @@ void main() { }); test('calculates pagination correctly', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockProfiles.count(where: any(named: 'where'))) .thenAnswer((_) async => 45); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockProfiles.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + computed: any(named: 'computed'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => []); final result = await repository.findMany(page: 1, pageSize: 20); @@ -129,11 +245,20 @@ void main() { }); test('hasNextPage is false on last page', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockProfiles.count(where: any(named: 'where'))) .thenAnswer((_) async => 10); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockProfiles.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + computed: any(named: 'computed'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => []); final result = await repository.findMany(page: 0, pageSize: 20); @@ -142,31 +267,40 @@ void main() { }); test('applies domain filter', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockProfiles.count(where: any(named: 'where'))) .thenAnswer((_) async => 1); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => [ - { - 'id': 'cp-1', - 'userId': 'user-1', - 'headline': 'Tech Expert', - 'description': 'desc', - 'rating': 4.0, - 'experience': 3, - 'languages': '[]', - 'toolsAndTechnologies': '[]', - 'totalMenteesHelped': 10, - 'isVerified': true, - 'domainId': 'dom-1', - 'createdAt': '2025-01-01T00:00:00.000Z', - 'user': {'name': 'Test', 'image': null}, - 'domain': {'id': 'dom-1', 'name': 'Tech'}, - 'minPrice': null, - 'priceCurrency': null, - 'subDomains': [], - }, - ]); + when( + () => mockProfiles.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + computed: any(named: 'computed'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => [ + { + 'id': 'cp-1', + 'userId': 'user-1', + 'headline': 'Tech Expert', + 'description': 'desc', + 'rating': 4.0, + 'experience': 3, + 'languages': '[]', + 'toolsAndTechnologies': '[]', + 'totalMenteesHelped': 10, + 'isVerified': true, + 'domainId': 'dom-1', + 'createdAt': '2025-01-01T00:00:00.000Z', + 'user': {'name': 'Test', 'image': null}, + 'domain': {'id': 'dom-1', 'name': 'Tech'}, + 'minPrice': null, + 'priceCurrency': null, + 'subDomains': >[], + }, + ]); final result = await repository.findMany(domainId: 'dom-1'); @@ -177,74 +311,105 @@ void main() { }); test('applies search query', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockProfiles.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockProfiles.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + computed: any(named: 'computed'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => []); final result = await repository.findMany(searchQuery: 'Flutter'); expect(result['consultants'], isEmpty); - verify(() => mockExecutor.executeCount(any())).called(1); - verify(() => mockExecutor.executeQueryAsMaps(any())).called(1); + verify(() => mockProfiles.count(where: any(named: 'where'))).called(1); + verify( + () => mockProfiles.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + computed: any(named: 'computed'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + include: any(named: 'include'), + ), + ).called(1); }); test('applies minimum rating filter', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockProfiles.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockProfiles.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + computed: any(named: 'computed'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => []); final result = await repository.findMany(minRating: 4.0); expect(result['consultants'], isEmpty); - verify(() => mockExecutor.executeCount(any())).called(1); + verify(() => mockProfiles.count(where: any(named: 'where'))).called(1); }); }); group('findByIdWithDetails', () { test('returns consultant details with plans and reviews', () async { // First call for profile query - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => [ - { - 'id': 'cp-1', - 'userId': 'user-1', - 'headline': 'Expert Dev', - 'description': 'Senior developer', - 'rating': 4.5, - 'experience': 5, - 'languages': '["English"]', - 'toolsAndTechnologies': '["Flutter"]', - 'totalMenteesHelped': 50, - 'isVerified': true, - 'domainId': 'dom-1', - 'mentoringStyle': 'Hands-on', - 'sessionTypes': '["VIDEO","CHAT"]', - 'websiteUrl': 'https://example.com', - 'twitterUrl': null, - 'githubUrl': 'https://github.com/test', - 'videoIntroUrl': null, - 'createdAt': '2025-01-01T00:00:00.000Z', - 'updatedAt': '2025-06-01T00:00:00.000Z', - 'user': { - 'name': 'John Doe', - 'image': null, - 'email': 'john@example.com', - 'timezone': 'Asia/Kolkata', - }, - 'domain': {'id': 'dom-1', 'name': 'Technology'}, - 'subDomains': [ - {'id': 'sd-1', 'name': 'Mobile Dev', 'domainId': 'dom-1'} - ], - 'tags': [ - {'name': 'flutter'}, - {'name': 'dart'}, - ], - }, - ]); + when( + () => mockProfiles.findFirstProjected( + where: any(named: 'where'), + select: any(named: 'select'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => { + 'id': 'cp-1', + 'userId': 'user-1', + 'headline': 'Expert Dev', + 'description': 'Senior developer', + 'rating': 4.5, + 'experience': 5, + 'languages': '["English"]', + 'toolsAndTechnologies': '["Flutter"]', + 'totalMenteesHelped': 50, + 'isVerified': true, + 'domainId': 'dom-1', + 'mentoringStyle': 'Hands-on', + 'sessionTypes': '["VIDEO","CHAT"]', + 'websiteUrl': 'https://example.com', + 'twitterUrl': null, + 'githubUrl': 'https://github.com/test', + 'videoIntroUrl': null, + 'createdAt': '2025-01-01T00:00:00.000Z', + 'updatedAt': '2025-06-01T00:00:00.000Z', + 'user': { + 'name': 'John Doe', + 'image': null, + 'email': 'john@example.com', + 'timezone': 'Asia/Kolkata', + }, + 'domain': {'id': 'dom-1', 'name': 'Technology'}, + 'subDomains': [ + {'id': 'sd-1', 'name': 'Mobile Dev', 'domainId': 'dom-1'} + ], + 'tags': [ + {'name': 'flutter'}, + {'name': 'dart'}, + ], + }); final result = await repository.findByIdWithDetails('cp-1'); @@ -259,8 +424,13 @@ void main() { }); test('returns null when consultant not found', () async { - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockProfiles.findFirstProjected( + where: any(named: 'where'), + select: any(named: 'select'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); final result = await repository.findByIdWithDetails('nonexistent'); @@ -270,48 +440,53 @@ void main() { group('getReviews', () { test('returns paginated reviews for consultant', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockReviews.count(where: any(named: 'where'))) .thenAnswer((_) async => 2); - var queryMapCallCount = 0; - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async { - queryMapCallCount++; - switch (queryMapCallCount) { - case 1: - // Reviews query - return [ - { - 'id': 'rev-1', - 'rating': 5, - 'reviewDescription': 'Excellent mentor', - 'consulteeProfileId': 'consultee-1', - 'createdAt': '2025-06-01T00:00:00.000Z', - }, - { - 'id': 'rev-2', - 'rating': 4, - 'reviewDescription': 'Very helpful', - 'consulteeProfileId': 'consultee-2', - 'createdAt': '2025-05-15T00:00:00.000Z', - }, - ]; - case 2: - // ConsulteeProfile query - return [ - {'id': 'consultee-1', 'userId': 'user-10'}, - {'id': 'consultee-2', 'userId': 'user-11'}, - ]; - case 3: - // Users query - return [ - {'id': 'user-10', 'name': 'Alice', 'image': null}, - {'id': 'user-11', 'name': 'Bob', 'image': 'bob.jpg'}, - ]; - default: - return []; - } - }); + when( + () => mockReviews.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + ), + ).thenAnswer((_) async => [ + { + 'id': 'rev-1', + 'rating': 5, + 'reviewDescription': 'Excellent mentor', + 'consulteeProfileId': 'consultee-1', + 'createdAt': '2025-06-01T00:00:00.000Z', + }, + { + 'id': 'rev-2', + 'rating': 4, + 'reviewDescription': 'Very helpful', + 'consulteeProfileId': 'consultee-2', + 'createdAt': '2025-05-15T00:00:00.000Z', + }, + ]); + + // Reviewer resolution: consulteeProfile -> user + when( + () => mockConsulteeProfiles.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + ), + ).thenAnswer((_) async => [ + {'id': 'consultee-1', 'userId': 'user-10'}, + {'id': 'consultee-2', 'userId': 'user-11'}, + ]); + when( + () => mockUsers.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + ), + ).thenAnswer((_) async => [ + {'id': 'user-10', 'name': 'Alice', 'image': null}, + {'id': 'user-11', 'name': 'Bob', 'image': 'bob.jpg'}, + ]); final result = await repository.getReviews( consultantId: 'cp-1', @@ -328,11 +503,18 @@ void main() { }); test('returns empty reviews when none exist', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockReviews.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockReviews.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + ), + ).thenAnswer((_) async => []); final result = await repository.getReviews(consultantId: 'cp-1'); @@ -341,12 +523,19 @@ void main() { }); test('handles reviews without consultee profile IDs', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockReviews.count(where: any(named: 'where'))) .thenAnswer((_) async => 1); var queryMapCallCount = 0; - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async { + when( + () => mockReviews.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + ), + ).thenAnswer((_) async { queryMapCallCount++; if (queryMapCallCount == 1) { return [ @@ -370,11 +559,18 @@ void main() { }); test('clamps page size to 50', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockReviews.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockReviews.findManyProjected( + where: any(named: 'where'), + select: any(named: 'select'), + orderBy: any(named: 'orderBy'), + take: any(named: 'take'), + skip: any(named: 'skip'), + ), + ).thenAnswer((_) async => []); final result = await repository.getReviews( consultantId: 'cp-1', diff --git a/backend/test/repositories/session_repository_test.dart b/backend/test/repositories/session_repository_test.dart index 10a8635..63e5cb2 100644 --- a/backend/test/repositories/session_repository_test.dart +++ b/backend/test/repositories/session_repository_test.dart @@ -1,39 +1,42 @@ import 'package:backend/database/repositories/session_repository.dart'; import 'package:backend/database/repositories/user_repository.dart'; -import 'package:backend/generated/prisma_client.dart'; import 'package:mocktail/mocktail.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; import 'package:test/test.dart'; +import '../helpers/prisma_mocks.dart'; + class MockQueryExecutor extends Mock implements QueryExecutor {} class MockUserRepository extends Mock implements UserRepository {} -class MockPrismaClient extends Mock implements PrismaClient {} - class FakeJsonQuery extends Fake implements JsonQuery {} void main() { late MockQueryExecutor mockExecutor; late MockUserRepository mockUserRepository; late MockPrismaClient mockPrisma; + late MockSessionDelegate mockSessions; late SessionRepository repository; setUpAll(() { registerFallbackValue(FakeJsonQuery()); + registerPrismaFallbacks(); }); setUp(() { mockExecutor = MockQueryExecutor(); mockUserRepository = MockUserRepository(); mockPrisma = MockPrismaClient(); + mockSessions = MockSessionDelegate(); + when(() => mockPrisma.session).thenReturn(mockSessions); repository = SessionRepository(mockExecutor, mockUserRepository, mockPrisma); }); group('deleteOtherSessions', () { test('executes deleteMany mutation successfully', () async { - when(() => mockExecutor.executeMutation(any())) + when(() => mockSessions.deleteMany(where: any(named: 'where'))) .thenAnswer((_) async => 3); await repository.deleteOtherSessions( @@ -41,11 +44,12 @@ void main() { keepSessionId: 'session-keep', ); - verify(() => mockExecutor.executeMutation(any())).called(1); + verify(() => mockSessions.deleteMany(where: any(named: 'where'))) + .called(1); }); test('does not throw when no other sessions exist', () async { - when(() => mockExecutor.executeMutation(any())) + when(() => mockSessions.deleteMany(where: any(named: 'where'))) .thenAnswer((_) async => 0); await expectLater( diff --git a/backend/test/repositories/support_ticket_repository_test.dart b/backend/test/repositories/support_ticket_repository_test.dart index 183b396..3ae341c 100644 --- a/backend/test/repositories/support_ticket_repository_test.dart +++ b/backend/test/repositories/support_ticket_repository_test.dart @@ -1,71 +1,100 @@ +// Mock stubbing spans several generated delegate classes that share no base +// type, so the shared stub helpers take `dynamic` — which costs type +// inference on the mocktail calls. Test-only plumbing, not a correctness gap. +// ignore_for_file: inference_failure_on_function_invocation, strict_raw_type + import 'package:backend/database/repositories/support_ticket_repository.dart'; import 'package:backend/generated/index.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:prisma_flutter_connector/runtime_server.dart' hide RecordNotFoundException; +import 'package:prisma_flutter_connector/runtime_server.dart' + hide RecordNotFoundException; import 'package:test/test.dart'; -class MockQueryExecutor extends Mock implements QueryExecutor {} +import '../helpers/prisma_mocks.dart'; -class MockPrismaClient extends Mock implements PrismaClient {} +class MockQueryExecutor extends Mock implements QueryExecutor {} class FakeJsonQuery extends Fake implements JsonQuery {} void main() { late MockQueryExecutor mockExecutor; + late MockPrismaClient mockPrisma; + late MockSupportTicketDelegate mockTickets; + late MockSupportResponseDelegate mockResponses; + late MockSupportTicketAttachmentDelegate mockAttachments; late SupportTicketRepository repository; setUpAll(() { registerFallbackValue(FakeJsonQuery()); + registerPrismaFallbacks(); }); setUp(() { mockExecutor = MockQueryExecutor(); - repository = SupportTicketRepository(mockExecutor, MockPrismaClient()); + mockPrisma = MockPrismaClient(); + mockTickets = MockSupportTicketDelegate(); + mockResponses = MockSupportResponseDelegate(); + mockAttachments = MockSupportTicketAttachmentDelegate(); + when(() => mockPrisma.supportTicket).thenReturn(mockTickets); + when(() => mockPrisma.supportResponse).thenReturn(mockResponses); + when(() => mockPrisma.supportTicketAttachment).thenReturn(mockAttachments); + repository = SupportTicketRepository(mockExecutor, mockPrisma); }); group('getTicketsByUserId', () { test('returns tickets with pagination', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockTickets.count(where: any(named: 'where'))) .thenAnswer((_) async => 2); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => [ - { - 'id': 'ticket-1', - 'title': 'Issue 1', - 'status': 'OPEN', - 'userId': 'user-1', - }, - { - 'id': 'ticket-2', - 'title': 'Issue 2', - 'status': 'OPEN', - 'userId': 'user-1', - }, - ]); + when( + () => mockTickets.findMany( + where: any(named: 'where'), + orderBy: any(named: 'orderBy'), + skip: any(named: 'skip'), + take: any(named: 'take'), + ), + ).thenAnswer( + (_) async => [ + buildSupportTicket(id: 'ticket-1', title: 'Issue 1'), + buildSupportTicket(id: 'ticket-2', title: 'Issue 2'), + ], + ); final result = await repository.getTicketsByUserId(userId: 'user-1'); expect(result['tickets'], hasLength(2)); expect(result['pagination']['totalCount'], equals(2)); expect(result['pagination']['page'], equals(0)); - verify(() => mockExecutor.executeCount(any())).called(1); - verify(() => mockExecutor.executeQueryAsMaps(any())).called(1); + verify(() => mockTickets.count(where: any(named: 'where'))).called(1); + verify( + () => mockTickets.findMany( + where: any(named: 'where'), + orderBy: any(named: 'orderBy'), + skip: any(named: 'skip'), + take: any(named: 'take'), + ), + ).called(1); }); test('filters tickets by status', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockTickets.count(where: any(named: 'where'))) .thenAnswer((_) async => 1); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => [ - { - 'id': 'ticket-1', - 'title': 'Issue 1', - 'status': 'RESOLVED', - 'userId': 'user-1', - }, - ]); + when( + () => mockTickets.findMany( + where: any(named: 'where'), + orderBy: any(named: 'orderBy'), + skip: any(named: 'skip'), + take: any(named: 'take'), + ), + ).thenAnswer( + (_) async => [ + buildSupportTicket( + id: 'ticket-1', + status: SupportTicketStatus.resolved, + ), + ], + ); final result = await repository.getTicketsByUserId( userId: 'user-1', @@ -77,11 +106,17 @@ void main() { }); test('returns empty list when no tickets', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockTickets.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockTickets.findMany( + where: any(named: 'where'), + orderBy: any(named: 'orderBy'), + skip: any(named: 'skip'), + take: any(named: 'take'), + ), + ).thenAnswer((_) async => []); final result = await repository.getTicketsByUserId(userId: 'user-2'); @@ -90,11 +125,17 @@ void main() { }); test('clamps page size to maximum 50', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockTickets.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockTickets.findMany( + where: any(named: 'where'), + orderBy: any(named: 'orderBy'), + skip: any(named: 'skip'), + take: any(named: 'take'), + ), + ).thenAnswer((_) async => []); final result = await repository.getTicketsByUserId( userId: 'user-1', @@ -105,11 +146,17 @@ void main() { }); test('supports pagination with page parameter', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockTickets.count(where: any(named: 'where'))) .thenAnswer((_) async => 25); - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async => []); + when( + () => mockTickets.findMany( + where: any(named: 'where'), + orderBy: any(named: 'orderBy'), + skip: any(named: 'skip'), + take: any(named: 'take'), + ), + ).thenAnswer((_) async => []); final result = await repository.getTicketsByUserId( userId: 'user-1', @@ -125,38 +172,25 @@ void main() { group('getTicketById', () { test('returns ticket with responses and attachments', () async { // First call: find ticket - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => { - 'id': 'ticket-1', - 'title': 'Test Issue', - 'description': 'Details here', - 'status': 'OPEN', - 'userId': 'user-1', - }); - - // executeQueryAsMaps: first for responses, then attachments - var queryAsMapsCallCount = 0; - when(() => mockExecutor.executeQueryAsMaps(any())) - .thenAnswer((_) async { - queryAsMapsCallCount++; - if (queryAsMapsCallCount == 1) { - // Responses - return [ - { - 'id': 'resp-1', - 'message': 'Response message', - 'isInternal': false, - }, - ]; - } - // Attachments - return [ - { - 'id': 'att-1', - 'fileName': 'screenshot.png', - }, - ]; - }); + when( + () => mockTickets.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => buildSupportTicket()); + + when( + () => mockResponses.findMany( + where: any(named: 'where'), + orderBy: any(named: 'orderBy'), + ), + ).thenAnswer((_) async => [buildSupportResponse()]); + when( + () => mockAttachments.findMany( + where: any(named: 'where'), + orderBy: any(named: 'orderBy'), + ), + ).thenAnswer((_) async => [buildSupportTicketAttachment()]); final result = await repository.getTicketById( ticketId: 'ticket-1', @@ -169,8 +203,12 @@ void main() { }); test('throws RecordNotFoundException when ticket not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); + when( + () => mockTickets.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); expect( () => repository.getTicketById( @@ -183,8 +221,12 @@ void main() { test('throws RecordNotFoundException when user does not own ticket', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); + when( + () => mockTickets.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); expect( () => repository.getTicketById( @@ -198,20 +240,19 @@ void main() { group('createTicket', () { test('creates ticket and returns the created record', () async { - // First call: executeMutation for create - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); - - // Second call: executeQueryAsSingleMap for fetch - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => { - 'id': 'generated-id', - 'title': 'New Issue', - 'description': 'Issue description', - 'status': 'OPEN', - 'priority': 'MEDIUM', - 'userId': 'user-1', - }); + final created = buildSupportTicket( + id: 'generated-id', + title: 'New Issue', + description: 'Issue description', + ); + when(() => mockTickets.create(data: any(named: 'data'))) + .thenAnswer((_) async => created); + when( + () => mockTickets.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => created); final result = await repository.createTicket( userId: 'user-1', @@ -222,47 +263,43 @@ void main() { expect(result['title'], equals('New Issue')); expect(result['status'], equals('OPEN')); expect(result['priority'], equals('MEDIUM')); - verify(() => mockExecutor.executeMutation(any())).called(1); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + verify(() => mockTickets.create(data: any(named: 'data'))).called(1); }); test('creates ticket with optional fields', () async { - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => { - 'id': 'generated-id', - 'title': 'Payment Issue', - 'description': 'Payment failed', - 'status': 'OPEN', - 'priority': 'HIGH', - 'issueType': 'PAYMENT', - 'category': 'BILLING', - 'paymentId': 'pay-1', - 'userId': 'user-1', - }); + final created = buildSupportTicket( + id: 'generated-id', + title: 'Payment Issue', + description: 'Payment failed', + priority: SupportPriority.high, + issueType: SupportIssueType.paymentFailed, + ); + when(() => mockTickets.create(data: any(named: 'data'))) + .thenAnswer((_) async => created); + when( + () => mockTickets.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => created); final result = await repository.createTicket( userId: 'user-1', title: 'Payment Issue', description: 'Payment failed', priority: 'HIGH', - issueType: 'PAYMENT', + issueType: 'PAYMENT_FAILED', category: 'BILLING', paymentId: 'pay-1', ); expect(result['priority'], equals('HIGH')); - expect(result['issueType'], equals('PAYMENT')); + expect(result['issueType'], equals('PAYMENT_FAILED')); }); test('throws when ticket creation fails', () async { - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); + when(() => mockTickets.create(data: any(named: 'data'))) + .thenAnswer((_) async => throw Exception('insert failed')); expect( () => repository.createTicket( @@ -277,30 +314,29 @@ void main() { group('addResponse', () { test('adds response to ticket and returns it', () async { - var singleMapCallCount = 0; - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async { - singleMapCallCount++; - if (singleMapCallCount == 1) { - // Verify ticket ownership - return { - 'id': 'ticket-1', - 'userId': 'user-1', - }; - } - // Return created response - return { - 'id': 'resp-1', - 'supportTicketId': 'ticket-1', - 'userId': 'user-1', - 'message': 'My response', - 'isInternal': false, - }; - }); - - // Create response + update ticket - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); + when( + () => mockTickets.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => buildSupportTicket()); + when(() => mockResponses.create(data: any(named: 'data'))).thenAnswer( + (_) async => buildSupportResponse(message: 'My response'), + ); + when( + () => mockTickets.update( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => buildSupportTicket()); + when( + () => mockResponses.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer( + (_) async => buildSupportResponse(message: 'My response'), + ); final result = await repository.addResponse( ticketId: 'ticket-1', @@ -310,13 +346,16 @@ void main() { expect(result['message'], equals('My response')); expect(result['isInternal'], isFalse); - // 2 mutations: create response + update ticket updatedAt - verify(() => mockExecutor.executeMutation(any())).called(2); + verify(() => mockResponses.create(data: any(named: 'data'))).called(1); }); test('throws RecordNotFoundException when ticket not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); + when( + () => mockTickets.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); expect( () => repository.addResponse( @@ -329,18 +368,14 @@ void main() { }); test('throws when response creation fails', () async { - var singleMapCallCount = 0; - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async { - singleMapCallCount++; - if (singleMapCallCount == 1) { - return {'id': 'ticket-1', 'userId': 'user-1'}; - } - return null; // Response fetch fails - }); - - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); + when( + () => mockTickets.findFirst( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => buildSupportTicket()); + when(() => mockResponses.create(data: any(named: 'data'))) + .thenAnswer((_) async => throw Exception('insert failed')); expect( () => repository.addResponse( @@ -357,7 +392,8 @@ void main() { test('returns counts for all statuses', () async { // 5 status queries + 1 total query = 6 calls var countCallCount = 0; - when(() => mockExecutor.executeCount(any())).thenAnswer((_) async { + when(() => mockTickets.count(where: any(named: 'where'))) + .thenAnswer((_) async { countCallCount++; switch (countCallCount) { case 1: @@ -385,11 +421,11 @@ void main() { expect(result['resolved'], equals(5)); expect(result['closed'], equals(4)); expect(result['total'], equals(15)); - verify(() => mockExecutor.executeCount(any())).called(6); + verify(() => mockTickets.count(where: any(named: 'where'))).called(6); }); test('returns zeros when no tickets exist', () async { - when(() => mockExecutor.executeCount(any())) + when(() => mockTickets.count(where: any(named: 'where'))) .thenAnswer((_) async => 0); final result = await repository.getTicketCountsByStatus('user-1'); diff --git a/backend/test/repositories/user_repository_test.dart b/backend/test/repositories/user_repository_test.dart index 68e87e6..6c9aae1 100644 --- a/backend/test/repositories/user_repository_test.dart +++ b/backend/test/repositories/user_repository_test.dart @@ -1,50 +1,49 @@ import 'package:backend/database/repositories/user_repository.dart'; -import 'package:backend/generated/prisma_client.dart'; +import 'package:backend/generated/index.dart'; import 'package:mocktail/mocktail.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; import 'package:test/test.dart'; -class MockQueryExecutor extends Mock implements QueryExecutor {} +import '../helpers/prisma_mocks.dart'; -class MockPrismaClient extends Mock implements PrismaClient {} +class MockQueryExecutor extends Mock implements QueryExecutor {} class FakeJsonQuery extends Fake implements JsonQuery {} void main() { late MockQueryExecutor mockExecutor; late MockPrismaClient mockPrisma; + late MockUserDelegate mockUsers; late UserRepository repository; setUpAll(() { registerFallbackValue(FakeJsonQuery()); + registerPrismaFallbacks(); }); setUp(() { mockExecutor = MockQueryExecutor(); mockPrisma = MockPrismaClient(); + mockUsers = MockUserDelegate(); + when(() => mockPrisma.user).thenReturn(mockUsers); repository = UserRepository(mockExecutor, mockPrisma); }); group('findByEmail', () { test('returns user when found', () async { - final expected = { - 'id': 'user-1', - 'email': 'test@example.com', - 'name': 'Test User', - 'role': 'CONSULTEE', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when(() => mockUsers.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildUser()); final result = await repository.findByEmail('test@example.com'); - expect(result, equals(expected)); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + expect(result?['id'], equals('user-1')); + expect(result?['email'], equals('test@example.com')); + expect(result?['role'], equals('CONSULTEE')); + verify(() => mockUsers.findFirst(where: any(named: 'where'))).called(1); }); test('returns null when user not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockUsers.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => null); final result = await repository.findByEmail('nonexistent@example.com'); @@ -55,24 +54,17 @@ void main() { group('findById', () { test('returns user when found', () async { - final expected = { - 'id': 'user-1', - 'email': 'test@example.com', - 'name': 'Test User', - 'role': 'CONSULTEE', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when(() => mockUsers.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildUser()); final result = await repository.findById('user-1'); - expect(result, equals(expected)); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + expect(result?['id'], equals('user-1')); + verify(() => mockUsers.findFirst(where: any(named: 'where'))).called(1); }); test('returns null when user not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockUsers.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => null); final result = await repository.findById('nonexistent-id'); @@ -83,17 +75,9 @@ void main() { group('create', () { test('creates user and returns result', () async { - final expected = { - 'id': 'user-1', - 'email': 'new@example.com', - 'name': 'New User', - 'role': 'CONSULTEE', - 'emailVerified': false, - 'onboardingCompleted': false, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when(() => mockUsers.create(data: any(named: 'data'))).thenAnswer( + (_) async => buildUser(email: 'new@example.com', name: 'New User'), + ); final result = await repository.create( id: 'user-1', @@ -101,22 +85,21 @@ void main() { name: 'New User', ); - expect(result, equals(expected)); + expect(result['email'], equals('new@example.com')); expect(result['emailVerified'], isFalse); expect(result['onboardingCompleted'], isFalse); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + verify(() => mockUsers.create(data: any(named: 'data'))).called(1); }); test('creates user with custom role', () async { - final expected = { - 'id': 'user-2', - 'email': 'consultant@example.com', - 'name': 'Consultant', - 'role': 'CONSULTANT', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when(() => mockUsers.create(data: any(named: 'data'))).thenAnswer( + (_) async => buildUser( + id: 'user-2', + email: 'consultant@example.com', + name: 'Consultant', + role: UserRole.consultant, + ), + ); final result = await repository.create( id: 'user-2', @@ -128,51 +111,58 @@ void main() { expect(result['role'], equals('CONSULTANT')); }); - test('throws when database fails to create user', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); - + test('throws on an unsupported role wire value', () async { expect( () => repository.create( id: 'user-1', email: 'fail@example.com', + role: 'WIZARD', ), - throwsA(isA()), + throwsA(isA()), ); }); }); group('update', () { test('updates user name and returns result', () async { - final expected = { - 'id': 'user-1', - 'name': 'Updated Name', - 'email': 'test@example.com', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when( + () => mockUsers.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => 1); + when(() => mockUsers.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildUser(name: 'Updated Name')); final result = await repository.update( id: 'user-1', name: 'Updated Name', ); - expect(result, equals(expected)); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + expect(result?['name'], equals('Updated Name')); + verify( + () => mockUsers.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).called(1); }); test('updates multiple fields', () async { - final expected = { - 'id': 'user-1', - 'name': 'Updated', - 'phone': '+1234567890', - 'city': 'New York', - 'country': 'US', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when( + () => mockUsers.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => 1); + when(() => mockUsers.findFirst(where: any(named: 'where'))).thenAnswer( + (_) async => buildUser( + name: 'Updated', + phone: '+1234567890', + city: 'New York', + country: 'US', + ), + ); final result = await repository.update( id: 'user-1', @@ -187,8 +177,12 @@ void main() { }); test('returns null when user not found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => null); + when( + () => mockUsers.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => 0); final result = await repository.update( id: 'nonexistent', @@ -196,18 +190,21 @@ void main() { ); expect(result, isNull); + // No re-read when nothing matched. + verifyNever(() => mockUsers.findFirst(where: any(named: 'where'))); }); }); group('updateEmailVerified', () { test('marks email as verified', () async { - final expected = { - 'id': 'user-1', - 'emailVerified': true, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when( + () => mockUsers.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => 1); + when(() => mockUsers.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildUser(emailVerified: true)); final result = await repository.updateEmailVerified( id: 'user-1', @@ -215,17 +212,17 @@ void main() { ); expect(result?['emailVerified'], isTrue); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); }); test('marks email as unverified', () async { - final expected = { - 'id': 'user-1', - 'emailVerified': false, - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when( + () => mockUsers.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => 1); + when(() => mockUsers.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildUser()); final result = await repository.updateEmailVerified( id: 'user-1', @@ -238,17 +235,19 @@ void main() { group('updateForOnboarding', () { test('updates user with onboarding data', () async { - final expected = { - 'id': 'user-1', - 'role': 'CONSULTEE', - 'name': 'Onboarded User', - 'onboardingCompleted': true, - 'phone': '+1234567890', - 'gender': 'MALE', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when( + () => mockUsers.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => 1); + when(() => mockUsers.findFirst(where: any(named: 'where'))).thenAnswer( + (_) async => buildUser( + name: 'Onboarded User', + onboardingCompleted: true, + phone: '+1234567890', + ), + ); final result = await repository.updateForOnboarding( id: 'user-1', @@ -261,20 +260,23 @@ void main() { expect(result?['onboardingCompleted'], isTrue); expect(result?['name'], equals('Onboarded User')); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); }); test('includes optional profile IDs when provided', () async { - final expected = { - 'id': 'user-1', - 'role': 'CONSULTANT', - 'name': 'Consultant', - 'onboardingCompleted': true, - 'consultantProfileId': 'cp-1', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when( + () => mockUsers.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => 1); + when(() => mockUsers.findFirst(where: any(named: 'where'))).thenAnswer( + (_) async => buildUser( + name: 'Consultant', + role: UserRole.consultant, + onboardingCompleted: true, + consultantProfileId: 'cp-1', + ), + ); final result = await repository.updateForOnboarding( id: 'user-1', @@ -286,29 +288,34 @@ void main() { expect(result?['consultantProfileId'], equals('cp-1')); }); - }); - group('delete', () { - test('deletes user successfully', () async { - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 1); + test('returns null when user not found', () async { + when( + () => mockUsers.updateMany( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer((_) async => 0); - await expectLater( - repository.delete('user-1'), - completes, + final result = await repository.updateForOnboarding( + id: 'nonexistent', + role: 'CONSULTEE', + name: 'Ghost', + onboardingCompleted: true, ); - verify(() => mockExecutor.executeMutation(any())).called(1); + expect(result, isNull); }); + }); - test('completes even when user does not exist', () async { - when(() => mockExecutor.executeMutation(any())) - .thenAnswer((_) async => 0); + group('delete', () { + test('deletes user successfully', () async { + when(() => mockUsers.delete(where: any(named: 'where'))) + .thenAnswer((_) async => buildUser()); - await expectLater( - repository.delete('nonexistent'), - completes, - ); + await expectLater(repository.delete('user-1'), completes); + + verify(() => mockUsers.delete(where: any(named: 'where'))).called(1); }); }); } diff --git a/backend/test/repositories/verification_repository_test.dart b/backend/test/repositories/verification_repository_test.dart index 3b83129..7a34c16 100644 --- a/backend/test/repositories/verification_repository_test.dart +++ b/backend/test/repositories/verification_repository_test.dart @@ -1,53 +1,55 @@ import 'package:backend/database/repositories/verification_repository.dart'; -import 'package:backend/generated/prisma_client.dart'; import 'package:mocktail/mocktail.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; import 'package:test/test.dart'; -class MockQueryExecutor extends Mock implements QueryExecutor {} +import '../helpers/prisma_mocks.dart'; -class MockPrismaClient extends Mock implements PrismaClient {} +class MockQueryExecutor extends Mock implements QueryExecutor {} class FakeJsonQuery extends Fake implements JsonQuery {} void main() { late MockQueryExecutor mockExecutor; late MockPrismaClient mockPrisma; + late MockVerificationDelegate mockVerifications; late VerificationRepository repository; setUpAll(() { registerFallbackValue(FakeJsonQuery()); + registerPrismaFallbacks(); }); setUp(() { mockExecutor = MockQueryExecutor(); mockPrisma = MockPrismaClient(); + mockVerifications = MockVerificationDelegate(); + when(() => mockPrisma.verification).thenReturn(mockVerifications); repository = VerificationRepository(mockExecutor, mockPrisma); }); group('findByValueAndIdentifierPrefix', () { test('returns matching verification', () async { - final expected = { - 'id': 'v1', - 'identifier': 'password-reset:test@example.com', - 'value': 'token123', - 'expiresAt': '2099-12-31T00:00:00.000Z', - }; - - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => expected); + when(() => mockVerifications.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildVerification(id: 'v1')); final result = await repository.findByValueAndIdentifierPrefix( value: 'token123', identifierPrefix: 'password-reset:', ); - expect(result, equals(expected)); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(1); + expect(result?['id'], equals('v1')); + expect(result?['value'], equals('token123')); + expect( + result?['identifier'], + equals('password-reset:test@example.com'), + ); + verify(() => mockVerifications.findFirst(where: any(named: 'where'))) + .called(1); }); test('returns null when no match found', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockVerifications.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => null); final result = await repository.findByValueAndIdentifierPrefix( @@ -59,7 +61,7 @@ void main() { }); test('distinguishes between different identifier prefixes', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockVerifications.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => null); await repository.findByValueAndIdentifierPrefix( @@ -72,23 +74,25 @@ void main() { identifierPrefix: 'password-reset:', ); - verify(() => mockExecutor.executeQueryAsSingleMap(any())).called(2); + verify(() => mockVerifications.findFirst(where: any(named: 'where'))) + .called(2); }); }); group('deleteExpired', () { test('returns affected row count', () async { - when(() => mockExecutor.executeMutation(any())) + when(() => mockVerifications.deleteMany(where: any(named: 'where'))) .thenAnswer((_) async => 5); final count = await repository.deleteExpired(); expect(count, equals(5)); - verify(() => mockExecutor.executeMutation(any())).called(1); + verify(() => mockVerifications.deleteMany(where: any(named: 'where'))) + .called(1); }); test('returns 0 when no expired verifications', () async { - when(() => mockExecutor.executeMutation(any())) + when(() => mockVerifications.deleteMany(where: any(named: 'where'))) .thenAnswer((_) async => 0); final count = await repository.deleteExpired(); diff --git a/backend/test/routes/appointments/index_test.dart b/backend/test/routes/appointments/index_test.dart index 02051e0..a852c31 100644 --- a/backend/test/routes/appointments/index_test.dart +++ b/backend/test/routes/appointments/index_test.dart @@ -1,3 +1,8 @@ +// Mock stubbing spans several generated delegate classes that share no base +// type, so the shared stub helpers take `dynamic` — which costs type +// inference on the mocktail calls. Test-only plumbing, not a correctness gap. +// ignore_for_file: inference_failure_on_function_invocation, strict_raw_type + import 'dart:io'; import 'package:backend/database/database_client.dart' hide Platform; @@ -295,8 +300,8 @@ void main() { 'planId': 'plan-123', }, ); - when(() => consulteeProfileRepo.findByUserId('user-123')) - .thenAnswer((_) async => {'id': 'consultee-456', 'userId': 'user-123'}); + when(() => consulteeProfileRepo.findByUserId('user-123')).thenAnswer( + (_) async => {'id': 'consultee-456', 'userId': 'user-123'}); final response = await route.onRequest(context); @@ -321,8 +326,8 @@ void main() { 'message': 'Looking forward to it', }, ); - when(() => consulteeProfileRepo.findByUserId('user-123')) - .thenAnswer((_) async => {'id': 'consultee-456', 'userId': 'user-123'}); + when(() => consulteeProfileRepo.findByUserId('user-123')).thenAnswer( + (_) async => {'id': 'consultee-456', 'userId': 'user-123'}); final bookingResult = { 'id': 'booking-789', @@ -360,8 +365,8 @@ void main() { 'planId': 'plan-123', }, ); - when(() => consulteeProfileRepo.findByUserId('user-123')) - .thenAnswer((_) async => {'id': 'consultee-456', 'userId': 'user-123'}); + when(() => consulteeProfileRepo.findByUserId('user-123')).thenAnswer( + (_) async => {'id': 'consultee-456', 'userId': 'user-123'}); final response = await route.onRequest(context); @@ -385,8 +390,8 @@ void main() { 'planId': 'plan-123', }, ); - when(() => consulteeProfileRepo.findByUserId('user-123')) - .thenAnswer((_) async => {'id': 'consultee-456', 'userId': 'user-123'}); + when(() => consulteeProfileRepo.findByUserId('user-123')).thenAnswer( + (_) async => {'id': 'consultee-456', 'userId': 'user-123'}); final response = await route.onRequest(context); @@ -413,8 +418,8 @@ void main() { 'slotStartTimes': ['2025-06-15T09:00:00Z'], }, ); - when(() => consulteeProfileRepo.findByUserId('user-123')) - .thenAnswer((_) async => {'id': 'consultee-456', 'userId': 'user-123'}); + when(() => consulteeProfileRepo.findByUserId('user-123')).thenAnswer( + (_) async => {'id': 'consultee-456', 'userId': 'user-123'}); when( () => appointmentRepo.createConsultationBooking( @@ -453,8 +458,8 @@ void main() { 'slotStartTimes': ['2025-06-15T09:00:00Z'], }, ); - when(() => consulteeProfileRepo.findByUserId('user-123')) - .thenAnswer((_) async => {'id': 'consultee-456', 'userId': 'user-123'}); + when(() => consulteeProfileRepo.findByUserId('user-123')).thenAnswer( + (_) async => {'id': 'consultee-456', 'userId': 'user-123'}); when( () => appointmentRepo.createConsultationBooking( diff --git a/backend/test/routes/checkout/verify_test.dart b/backend/test/routes/checkout/verify_test.dart index 2a0d8b9..d4dde4c 100644 --- a/backend/test/routes/checkout/verify_test.dart +++ b/backend/test/routes/checkout/verify_test.dart @@ -8,6 +8,8 @@ import 'package:mocktail/mocktail.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; import 'package:test/test.dart'; +import '../../helpers/prisma_mocks.dart'; + import '../../../routes/api/checkout/verify.dart' as route; class _MockRequestContext extends Mock implements RequestContext {} @@ -26,6 +28,9 @@ class _FakeJsonQuery extends Fake implements JsonQuery {} void main() { setUpAll(() { + registerPrismaFallbacks(); + registerExploreFallbacks(); + registerBookingFallbacks(); registerFallbackValue(_FakeJsonQuery()); }); @@ -35,6 +40,10 @@ void main() { late _MockJwtService jwtService; late _MockCheckoutRepository checkoutRepo; late _MockQueryExecutor executor; + late MockPrismaClient prisma; + late MockPaymentDelegate payments; + late MockAppointmentDelegate appointments; + late MockSlotOfAppointmentDelegate slots; setUp(() { context = _MockRequestContext(); @@ -50,6 +59,27 @@ void main() { when(() => db.checkout).thenReturn(checkoutRepo); when(() => db.executor).thenReturn(executor); + + prisma = MockPrismaClient(); + payments = MockPaymentDelegate(); + appointments = MockAppointmentDelegate(); + slots = MockSlotOfAppointmentDelegate(); + when(() => db.prisma).thenReturn(prisma); + when(() => prisma.payment).thenReturn(payments); + when(() => prisma.appointment).thenReturn(appointments); + when(() => prisma.slotOfAppointment).thenReturn(slots); + when( + () => appointments.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); + when( + () => slots.findFirst( + where: any(named: 'where'), + orderBy: any(named: 'orderBy'), + ), + ).thenAnswer((_) async => null); }); group('GET /api/checkout/verify', () { @@ -125,7 +155,7 @@ void main() { ), ); when( - () => executor.executeQueryAsSingleMap(any()), + () => payments.findUnique(where: any(named: 'where')), ).thenAnswer((_) async => null); final response = await route.onRequest(context); @@ -151,15 +181,16 @@ void main() { // First call returns the payment record when( - () => executor.executeQueryAsSingleMap(any()), - ).thenAnswer((_) async => { - 'id': 'pay-123', - 'appointmentId': null, - 'paymentStatus': 'SUCCEEDED', - }); + () => payments.findUnique(where: any(named: 'where')), + ).thenAnswer( + (_) async => buildPayment( + id: 'pay-123', + paymentStatus: PaymentStatus.succeeded, + userId: 'user-123', + ), + ); final response = await route.onRequest(context); - expect(response.statusCode, equals(io.HttpStatus.ok)); final body = await response.json(); expect(body['success'], isTrue); @@ -180,12 +211,14 @@ void main() { ); when( - () => executor.executeQueryAsSingleMap(any()), - ).thenAnswer((_) async => { - 'id': 'pay-123', - 'appointmentId': null, - 'paymentStatus': 'FAILED', - }); + () => payments.findUnique(where: any(named: 'where')), + ).thenAnswer( + (_) async => buildPayment( + id: 'pay-123', + paymentStatus: PaymentStatus.failed, + userId: 'user-123', + ), + ); final response = await route.onRequest(context); @@ -210,12 +243,15 @@ void main() { // Payment exists but is PENDING (not yet processed) when( - () => executor.executeQueryAsSingleMap(any()), - ).thenAnswer((_) async => { - 'id': 'pay-123', - 'appointmentId': null, - 'paymentStatus': 'PENDING', - }); + () => payments.findUnique(where: any(named: 'where')), + ).thenAnswer( + (_) async => buildPayment( + id: 'pay-123', + paymentStatus: PaymentStatus.pending, + userId: 'user-123', + paymentGateway: PaymentGateway.razorpay, + ), + ); final response = await route.onRequest(context); diff --git a/backend/test/routes/support/index_test.dart b/backend/test/routes/support/index_test.dart index e5e894c..0f4ddc9 100644 --- a/backend/test/routes/support/index_test.dart +++ b/backend/test/routes/support/index_test.dart @@ -1,3 +1,8 @@ +// Mock stubbing spans several generated delegate classes that share no base +// type, so the shared stub helpers take `dynamic` — which costs type +// inference on the mocktail calls. Test-only plumbing, not a correctness gap. +// ignore_for_file: inference_failure_on_function_invocation, strict_raw_type + import 'dart:io'; import 'package:backend/database/database_client.dart' hide Platform; diff --git a/backend/test/services/auth/auth_service_test.dart b/backend/test/services/auth/auth_service_test.dart index 164792d..3371213 100644 --- a/backend/test/services/auth/auth_service_test.dart +++ b/backend/test/services/auth/auth_service_test.dart @@ -9,6 +9,8 @@ import 'package:backend/services/auth/jwt_service.dart'; import 'package:bcrypt/bcrypt.dart'; import 'package:mocktail/mocktail.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; + +import '../../helpers/prisma_mocks.dart'; import 'package:test/test.dart'; // Mocks @@ -37,11 +39,18 @@ void main() { late MockAccountRepository mockAccounts; late MockSessionRepository mockSessions; late MockConsulteeProfileRepository mockConsulteeProfiles; + late MockPrismaClient mockPrisma; + late MockUserDelegate mockUserDelegate; + late MockAccountDelegate mockAccountDelegate; + late MockConsulteeProfileDelegate mockConsulteeProfileDelegate; + late MockCookiePreferenceDelegate mockCookiePreferenceDelegate; + late MockNotificationPreferenceDelegate mockNotificationPreferenceDelegate; late AuthService service; setUpAll(() { registerFallbackValue(FakeTransactionExecutor()); registerFallbackValue(DateTime.now()); + registerPrismaFallbacks(); }); setUp(() { @@ -58,6 +67,45 @@ void main() { when(() => mockDb.sessions).thenReturn(mockSessions); when(() => mockDb.consulteeProfiles).thenReturn(mockConsulteeProfiles); + // Typed Prisma surface: signup/OAuth flows now run inside + // db.prisma.$transaction with typed delegate creates. + mockPrisma = MockPrismaClient(); + mockUserDelegate = MockUserDelegate(); + mockAccountDelegate = MockAccountDelegate(); + mockConsulteeProfileDelegate = MockConsulteeProfileDelegate(); + mockCookiePreferenceDelegate = MockCookiePreferenceDelegate(); + mockNotificationPreferenceDelegate = MockNotificationPreferenceDelegate(); + when(() => mockDb.prisma).thenReturn(mockPrisma); + when(() => mockPrisma.user).thenReturn(mockUserDelegate); + when(() => mockPrisma.account).thenReturn(mockAccountDelegate); + when(() => mockPrisma.consulteeProfile) + .thenReturn(mockConsulteeProfileDelegate); + when(() => mockPrisma.cookiePreference) + .thenReturn(mockCookiePreferenceDelegate); + when(() => mockPrisma.notificationPreference) + .thenReturn(mockNotificationPreferenceDelegate); + stubTransaction>(mockPrisma); + + when(() => mockUserDelegate.create(data: any(named: 'data'))) + .thenAnswer((_) async => buildUser()); + when( + () => mockUserDelegate.update( + where: any(named: 'where'), + data: any(named: 'data'), + ), + ).thenAnswer( + (_) async => buildUser(consulteeProfileId: 'consultee-profile-1'), + ); + when(() => mockAccountDelegate.create(data: any(named: 'data'))) + .thenAnswer((_) async => buildAccount()); + when(() => mockConsulteeProfileDelegate.create(data: any(named: 'data'))) + .thenAnswer((_) async => buildConsulteeProfile()); + when(() => mockCookiePreferenceDelegate.create(data: any(named: 'data'))) + .thenAnswer((_) async => buildCookiePreference()); + when( + () => mockNotificationPreferenceDelegate.create(data: any(named: 'data')), + ).thenAnswer((_) async => buildNotificationPreference()); + // Default stub for createDefaultPreferences (called during signup) when(() => mockUsers.createDefaultPreferences( any(), @@ -470,9 +518,7 @@ void main() { ); }); - test( - 'throws AuthException when both tokens are empty strings', - () async { + test('throws AuthException when both tokens are empty strings', () async { expect( () => service.signInWithGoogle( idToken: '', diff --git a/backend/test/services/email_service_test.dart b/backend/test/services/email_service_test.dart index f6b6177..8c34a2c 100644 --- a/backend/test/services/email_service_test.dart +++ b/backend/test/services/email_service_test.dart @@ -1,8 +1,5 @@ -import 'dart:convert'; - import 'package:backend/services/email/email_service.dart'; import 'package:http/http.dart' as http; -import 'package:http/testing.dart' as http_testing; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; @@ -30,16 +27,10 @@ void main() { }); group('sendPasswordResetEmail', () { - late MockHttpClient mockClient; - setUpAll(() { registerFallbackValue(Uri.parse('https://api.resend.com/emails')); }); - setUp(() { - mockClient = MockHttpClient(); - }); - test('sends email with correct subject and contains reset URL', () async { // Arrange // We cannot easily inject HTTP client into EmailService since it @@ -69,8 +60,7 @@ void main() { // service configuration and verify the email sending path throws // properly for non-200 responses. - test( - 'sendPasswordResetEmail throws Exception on non-200 HTTP response', + test('sendPasswordResetEmail throws Exception on non-200 HTTP response', () async { // Note: EmailService uses the global http.post function, which cannot // easily be mocked without dependency injection. We test that the @@ -90,8 +80,7 @@ void main() { ); }); - test( - 'sendVerificationEmail throws Exception on non-200 HTTP response', + test('sendVerificationEmail throws Exception on non-200 HTTP response', () async { final service = EmailService(apiKey: 'invalid-key'); diff --git a/backend/test/services/profile_service_test.dart b/backend/test/services/profile_service_test.dart index 5c03c16..07d6b3b 100644 --- a/backend/test/services/profile_service_test.dart +++ b/backend/test/services/profile_service_test.dart @@ -63,10 +63,12 @@ void main() { .toIso8601String(), }; - when(() => mockVerifications.findByValueAndIdentifierPrefix( - value: any(named: 'value'), - identifierPrefix: any(named: 'identifierPrefix'), - ),).thenAnswer((_) async => verification); + when( + () => mockVerifications.findByValueAndIdentifierPrefix( + value: any(named: 'value'), + identifierPrefix: any(named: 'identifierPrefix'), + ), + ).thenAnswer((_) async => verification); when(() => mockUsers.findByEmail(any())) .thenAnswer((_) async => {'id': 'u1', 'email': 'test@example.com'}); @@ -74,10 +76,12 @@ void main() { when(() => mockAccounts.findCredentialAccount(any())) .thenAnswer((_) async => {'id': 'a1', 'password': 'oldhash'}); - when(() => mockAccounts.updatePassword( - accountId: any(named: 'accountId'), - hashedPassword: any(named: 'hashedPassword'), - ),).thenAnswer((_) async => {}); + when( + () => mockAccounts.updatePassword( + accountId: any(named: 'accountId'), + hashedPassword: any(named: 'hashedPassword'), + ), + ).thenAnswer((_) async => {}); when(() => mockVerifications.delete(any())).thenAnswer((_) async {}); @@ -86,17 +90,21 @@ void main() { newPassword: 'newPassword123', ); - verify(() => mockVerifications.findByValueAndIdentifierPrefix( - value: 'token123', - identifierPrefix: 'password-reset:', - ),).called(1); + verify( + () => mockVerifications.findByValueAndIdentifierPrefix( + value: 'token123', + identifierPrefix: 'password-reset:', + ), + ).called(1); }); test('throws AuthException when verification is null', () async { - when(() => mockVerifications.findByValueAndIdentifierPrefix( - value: any(named: 'value'), - identifierPrefix: any(named: 'identifierPrefix'), - ),).thenAnswer((_) async => null); + when( + () => mockVerifications.findByValueAndIdentifierPrefix( + value: any(named: 'value'), + identifierPrefix: any(named: 'identifierPrefix'), + ), + ).thenAnswer((_) async => null); expect( () => service.resetPassword( @@ -118,10 +126,12 @@ void main() { .toIso8601String(), }; - when(() => mockVerifications.findByValueAndIdentifierPrefix( - value: any(named: 'value'), - identifierPrefix: any(named: 'identifierPrefix'), - ),).thenAnswer((_) async => verification); + when( + () => mockVerifications.findByValueAndIdentifierPrefix( + value: any(named: 'value'), + identifierPrefix: any(named: 'identifierPrefix'), + ), + ).thenAnswer((_) async => verification); when(() => mockVerifications.delete(any())).thenAnswer((_) async {}); @@ -147,34 +157,42 @@ void main() { .toIso8601String(), }; - when(() => mockVerifications.findByValueAndIdentifierPrefix( - value: any(named: 'value'), - identifierPrefix: any(named: 'identifierPrefix'), - ),).thenAnswer((_) async => verification); + when( + () => mockVerifications.findByValueAndIdentifierPrefix( + value: any(named: 'value'), + identifierPrefix: any(named: 'identifierPrefix'), + ), + ).thenAnswer((_) async => verification); when(() => mockUsers.findByEmail(any())) .thenAnswer((_) async => {'id': 'u1', 'email': 'test@example.com'}); - when(() => mockUsers.updateEmailVerified( - id: any(named: 'id'), - verified: any(named: 'verified'), - ),).thenAnswer((_) async => {}); + when( + () => mockUsers.updateEmailVerified( + id: any(named: 'id'), + verified: any(named: 'verified'), + ), + ).thenAnswer((_) async => {}); when(() => mockVerifications.delete(any())).thenAnswer((_) async {}); await service.confirmEmailVerification(token: 'verify-token'); - verify(() => mockVerifications.findByValueAndIdentifierPrefix( - value: 'verify-token', - identifierPrefix: 'email-verify:', - ),).called(1); + verify( + () => mockVerifications.findByValueAndIdentifierPrefix( + value: 'verify-token', + identifierPrefix: 'email-verify:', + ), + ).called(1); }); test('throws AuthException when verification is null', () async { - when(() => mockVerifications.findByValueAndIdentifierPrefix( - value: any(named: 'value'), - identifierPrefix: any(named: 'identifierPrefix'), - ),).thenAnswer((_) async => null); + when( + () => mockVerifications.findByValueAndIdentifierPrefix( + value: any(named: 'value'), + identifierPrefix: any(named: 'identifierPrefix'), + ), + ).thenAnswer((_) async => null); expect( () => service.confirmEmailVerification(token: 'bad-token'), diff --git a/backend/test/services/stream_service_test.dart b/backend/test/services/stream_service_test.dart index a67d189..a5daaa9 100644 --- a/backend/test/services/stream_service_test.dart +++ b/backend/test/services/stream_service_test.dart @@ -1,3 +1,8 @@ +// Mock stubbing spans several generated delegate classes that share no base +// type, so the shared stub helpers take `dynamic` — which costs type +// inference on the mocktail calls. Test-only plumbing, not a correctness gap. +// ignore_for_file: inference_failure_on_function_invocation, strict_raw_type + import 'package:backend/services/stream_service.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:test/test.dart'; diff --git a/backend/test/services/webhook_handlers_test.dart b/backend/test/services/webhook_handlers_test.dart index ead0e2d..cb3fa76 100644 --- a/backend/test/services/webhook_handlers_test.dart +++ b/backend/test/services/webhook_handlers_test.dart @@ -8,6 +8,8 @@ import 'package:mocktail/mocktail.dart'; import 'package:prisma_flutter_connector/runtime_server.dart'; import 'package:test/test.dart'; +import '../helpers/prisma_mocks.dart'; + // Mocks class MockDatabaseClient extends Mock implements DatabaseClient {} @@ -26,6 +28,9 @@ class FakeJsonQuery extends Fake implements JsonQuery {} void main() { setUpAll(() { registerFallbackValue(FakeJsonQuery()); + registerPrismaFallbacks(); + registerExploreFallbacks(); + registerBookingFallbacks(); }); late MockDatabaseClient mockDb; @@ -34,6 +39,10 @@ void main() { late MockDisputeRepository mockDisputes; late MockStreamService mockStreamService; late MockQueryExecutor mockExecutor; + late MockPrismaClient mockPrisma; + late MockPaymentDelegate mockPayments; + late MockAppointmentDelegate mockAppointments; + late MockConsultantProfileDelegate mockConsultantProfiles; late WebhookHandlers handlers; setUp(() { @@ -49,19 +58,38 @@ void main() { when(() => mockDb.disputes).thenReturn(mockDisputes); when(() => mockDb.executor).thenReturn(mockExecutor); + // Typed Prisma surface used by the payment/appointment lookups. + mockPrisma = MockPrismaClient(); + mockPayments = MockPaymentDelegate(); + mockAppointments = MockAppointmentDelegate(); + mockConsultantProfiles = MockConsultantProfileDelegate(); + when(() => mockDb.prisma).thenReturn(mockPrisma); + when(() => mockPrisma.payment).thenReturn(mockPayments); + when(() => mockPrisma.appointment).thenReturn(mockAppointments); + when(() => mockPrisma.consultantProfile).thenReturn(mockConsultantProfiles); + when( + () => mockAppointments.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); + when( + () => mockConsultantProfiles.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer((_) async => null); + handlers = WebhookHandlers(mockDb, streamService: mockStreamService); }); group('handlePaymentSuccess', () { test('updates payment status to SUCCEEDED', () async { // Arrange - final payment = { - 'id': 'payment-1', - 'appointmentId': null, - 'paymentStatus': 'PENDING', - }; + final payment = + buildPayment(id: 'payment-1', paymentStatus: PaymentStatus.pending); - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => payment); when(() => mockCheckout.updatePaymentStatus( @@ -84,7 +112,7 @@ void main() { test('skips processing when payment not found', () async { // Arrange - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => null); // Act @@ -102,13 +130,10 @@ void main() { test('skips processing when payment already SUCCEEDED', () async { // Arrange - final payment = { - 'id': 'payment-1', - 'appointmentId': null, - 'paymentStatus': 'SUCCEEDED', - }; + final payment = + buildPayment(id: 'payment-1', paymentStatus: PaymentStatus.succeeded); - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => payment); // Act @@ -127,30 +152,19 @@ void main() { test('confirms booking when appointmentId is present (consultation)', () async { // Arrange - final payment = { - 'id': 'payment-1', - 'appointmentId': 'appointment-1', - 'paymentStatus': 'PENDING', - }; - - var singleMapCallCount = 0; - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async { - singleMapCallCount++; - if (singleMapCallCount == 1) { - // First call: _findPaymentByIntent - return payment; - } - // Second call: appointment query - return { - 'id': 'appointment-1', - 'consultationId': 'consultation-1', - 'subscriptionId': null, - 'webinarId': null, - 'classId': null, - 'appointmentType': 'CONSULTATION', - }; - }); + final payment = + buildPayment(id: 'payment-1', paymentStatus: PaymentStatus.pending); + + when(() => mockPayments.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => payment); + when( + () => mockAppointments.findUnique( + where: any(named: 'where'), + include: any(named: 'include'), + ), + ).thenAnswer( + (_) async => buildAppointment(id: 'appointment-1'), + ); when(() => mockCheckout.updatePaymentStatus( paymentId: any(named: 'paymentId'), @@ -182,12 +196,10 @@ void main() { group('handlePaymentFailure', () { test('updates payment status to FAILED', () async { // Arrange - final payment = { - 'id': 'payment-2', - 'paymentStatus': 'PENDING', - }; + final payment = + buildPayment(id: 'payment-2', paymentStatus: PaymentStatus.pending); - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => payment); when(() => mockCheckout.updatePaymentStatus( @@ -211,7 +223,7 @@ void main() { test('skips when payment not found', () async { // Arrange - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => null); // Act @@ -229,12 +241,10 @@ void main() { test('skips when payment already FAILED', () async { // Arrange - final payment = { - 'id': 'payment-2', - 'paymentStatus': 'FAILED', - }; + final payment = + buildPayment(id: 'payment-2', paymentStatus: PaymentStatus.failed); - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => payment); // Act @@ -252,12 +262,10 @@ void main() { test('skips when payment already SUCCEEDED', () async { // Arrange - final payment = { - 'id': 'payment-3', - 'paymentStatus': 'SUCCEEDED', - }; + final payment = + buildPayment(id: 'payment-3', paymentStatus: PaymentStatus.succeeded); - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => payment); // Act @@ -277,12 +285,10 @@ void main() { group('handleRefundProcessed', () { test('creates refund record with mapped status', () async { // Arrange - final payment = { - 'id': 'payment-4', - 'paymentStatus': 'SUCCEEDED', - }; + final payment = + buildPayment(id: 'payment-4', paymentStatus: PaymentStatus.succeeded); - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => payment); when(() => mockRefunds.createRefund( @@ -320,7 +326,7 @@ void main() { test('skips when payment not found', () async { // Arrange - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => null); // Act @@ -347,8 +353,8 @@ void main() { test('maps "processed" status to SUCCEEDED', () async { // Arrange - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => {'id': 'payment-5'}); + when(() => mockPayments.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildPayment(id: 'payment-5')); when(() => mockRefunds.createRefund( refundId: any(named: 'refundId'), @@ -383,8 +389,8 @@ void main() { }); test('maps "pending" status to PENDING', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => {'id': 'payment-6'}); + when(() => mockPayments.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildPayment(id: 'payment-6')); when(() => mockRefunds.createRefund( refundId: any(named: 'refundId'), @@ -417,8 +423,8 @@ void main() { }); test('maps unknown status to PENDING', () async { - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => {'id': 'payment-7'}); + when(() => mockPayments.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildPayment(id: 'payment-7')); when(() => mockRefunds.createRefund( refundId: any(named: 'refundId'), @@ -454,12 +460,10 @@ void main() { group('handleDisputeCreated', () { test('creates dispute record', () async { // Arrange - final payment = { - 'id': 'payment-8', - 'paymentStatus': 'SUCCEEDED', - }; + final payment = + buildPayment(id: 'payment-8', paymentStatus: PaymentStatus.succeeded); - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => payment); when(() => mockDisputes.createDispute( @@ -501,7 +505,7 @@ void main() { test('skips when payment not found', () async { // Arrange - when(() => mockExecutor.executeQueryAsSingleMap(any())) + when(() => mockPayments.findFirst(where: any(named: 'where'))) .thenAnswer((_) async => null); // Act @@ -531,8 +535,8 @@ void main() { test('passes dueBy and isChargeRefundable fields', () async { // Arrange - when(() => mockExecutor.executeQueryAsSingleMap(any())) - .thenAnswer((_) async => {'id': 'payment-9'}); + when(() => mockPayments.findFirst(where: any(named: 'where'))) + .thenAnswer((_) async => buildPayment(id: 'payment-9')); final dueDate = DateTime(2025, 6, 15); diff --git a/backend/test/utils/pan_crypto_test.dart b/backend/test/utils/pan_crypto_test.dart new file mode 100644 index 0000000..9d66f82 --- /dev/null +++ b/backend/test/utils/pan_crypto_test.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:backend/utils/pan_crypto.dart'; +import 'package:test/test.dart'; + +void main() { + // 32-byte key (64 hex chars), fixed for determinism. + const keyHex = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + group('PanCrypto', () { + test('round-trips a PAN', () { + const pan = 'ABCDE1234F'; + final sealed = PanCrypto.encrypt(pan, keyHex: keyHex); + expect(PanCrypto.decrypt(sealed, keyHex: keyHex), pan); + }); + + test('emits web wire format [12B IV][ciphertext][16B tag]', () { + const pan = 'ABCDE1234F'; + final sealed = PanCrypto.encrypt(pan, keyHex: keyHex); + // 12 (IV) + len(pan) ciphertext + 16 (tag) + expect(sealed.length, 12 + utf8.encode(pan).length + 16); + }); + + test('ciphertext is non-deterministic (random IV)', () { + const pan = 'ABCDE1234F'; + final a = PanCrypto.encrypt(pan, keyHex: keyHex); + final b = PanCrypto.encrypt(pan, keyHex: keyHex); + expect(a, isNot(equals(b))); + // ...but both decrypt to the same plaintext. + expect(PanCrypto.decrypt(a, keyHex: keyHex), pan); + expect(PanCrypto.decrypt(b, keyHex: keyHex), pan); + }); + + test('wrong key fails to decrypt (auth tag rejects)', () { + final sealed = PanCrypto.encrypt('ABCDE1234F', keyHex: keyHex); + const otherKey = + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; + expect( + () => PanCrypto.decrypt(sealed, keyHex: otherKey), + throwsA(isA()), + ); + }); + + test('missing/short key fails closed on encrypt', () { + expect(() => PanCrypto.encrypt('ABCDE1234F', keyHex: ''), + throwsA(isA())); + expect(() => PanCrypto.encrypt('ABCDE1234F', keyHex: 'abcd'), + throwsA(isA())); + }); + + test('decrypts a real Node-produced fixture (cross-app compatibility)', () { + // Generated by Node's crypto (the same primitive familiarise_web + // pan-crypto.ts uses) with the key above, PAN 'ABCDE1234F', and a fixed + // IV 0x0102..0c, as base64 of [IV||ciphertext||16B tag]: + // node -e "const {createCipheriv}=require('crypto'); + // const k=Buffer.from(KEY,'hex'),iv=Buffer.from('0102...0c','hex'); + // const c=createCipheriv('aes-256-gcm',k,iv); + // const e=Buffer.concat([c.update('ABCDE1234F'),c.final()]); + // console.log(Buffer.concat([iv,e,c.getAuthTag()]).toString('base64'))" + // This proves the Dart decryptor reads Node-encrypted ciphertext. + const nodeFixtureB64 = + 'AQIDBAUGBwgJCgsMhv/YC1CS7ptF2a8fdWIyaZYUhYqWHkkhlYA='; + final sealed = Uint8List.fromList(base64.decode(nodeFixtureB64)); + expect(PanCrypto.decrypt(sealed, keyHex: keyHex), 'ABCDE1234F'); + }); + }); +} diff --git a/backend/tool/merge_probe.dart b/backend/tool/merge_probe.dart new file mode 100644 index 0000000..6b8fb5b --- /dev/null +++ b/backend/tool/merge_probe.dart @@ -0,0 +1,13 @@ +import 'package:backend/database/database_client.dart'; +import 'package:dotenv/dotenv.dart'; + +Future main() async { + final env = DotEnv()..load(['.env']); + final db = await DatabaseClient.initialize(env['DIRECT_URL']!); + try { + final tags = await db.prisma.tag.findMany(take: 1); + print('OK tags=${tags.length}'); + } catch (e) { + print('ERR: ${e.toString().split("\n").first}'); + } +} diff --git a/lib/app/theme/app_theme.dart b/lib/app/theme/app_theme.dart index 00bbc26..20116af 100644 --- a/lib/app/theme/app_theme.dart +++ b/lib/app/theme/app_theme.dart @@ -16,6 +16,8 @@ class AppTheme { static const _lightPrimaryForeground = Color(0xFFFAFAFA); static const _lightSecondary = Color(0xFFF4F4F5); static const _lightSecondaryForeground = Color(0xFF18181B); + // Part of the shadcn token set; retained for parity with the web palette. + // ignore: unused_field static const _lightMuted = Color(0xFFF4F4F5); static const _lightMutedForeground = Color(0xFF71717A); static const _lightBorder = Color(0xFFE4E4E7); @@ -27,6 +29,7 @@ class AppTheme { static const _darkPrimaryForeground = Color(0xFF18181B); static const _darkSecondary = Color(0xFF27272A); static const _darkSecondaryForeground = Color(0xFFFAFAFA); + // ignore: unused_field static const _darkMuted = Color(0xFF27272A); static const _darkMutedForeground = Color(0xFFA1A1AA); static const _darkBorder = Color(0xFF27272A); diff --git a/lib/core/config/env_config.dart b/lib/core/config/env_config.dart index 2f9de4f..9a76465 100644 --- a/lib/core/config/env_config.dart +++ b/lib/core/config/env_config.dart @@ -41,10 +41,10 @@ abstract class EnvConfig { // API @EnviedField(varName: 'API_BASE_URL', defaultValue: 'http://localhost:3000') - static String _apiBaseUrlRaw = _EnvConfig._apiBaseUrlRaw; + static final String _apiBaseUrlRaw = _EnvConfig._apiBaseUrlRaw; @EnviedField(varName: 'PHYSICAL_DEVICE_API_URL', defaultValue: '') - static String _physicalDeviceApiUrl = _EnvConfig._physicalDeviceApiUrl; + static final String _physicalDeviceApiUrl = _EnvConfig._physicalDeviceApiUrl; // Device type flag (set at startup via initializeDeviceDetection) static bool _isPhysicalDevice = false; diff --git a/lib/core/network/api_endpoints.dart b/lib/core/network/api_endpoints.dart index a73a51b..c8e9ddd 100644 --- a/lib/core/network/api_endpoints.dart +++ b/lib/core/network/api_endpoints.dart @@ -98,8 +98,7 @@ abstract final class ApiEndpoints { '$api/consultant/dashboard/pending-requests'; static const String consultantRecentReviews = '$api/consultant/dashboard/recent-reviews'; - static const String consultantEarnings = - '$api/consultant/dashboard/earnings'; + static const String consultantEarnings = '$api/consultant/dashboard/earnings'; // Profile update (role-specific) static const String consultantProfile = '$api/consultant/profile'; diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 551d1a2..74c910f 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -44,7 +44,7 @@ Dio dio(Ref ref) { return dio; } -/// Interceptor to convert _JsonMap to Map on web +/// Interceptor to convert _JsonMap to `Map` on web class JsonMapConversionInterceptor extends Interceptor { @override void onResponse(Response response, ResponseInterceptorHandler handler) { @@ -232,7 +232,7 @@ class ErrorInterceptor extends Interceptor { ); } - /// Safely convert response data to Map + /// Safely convert response data to `Map` /// Handles _JsonMap on Flutter web Map? _safeMapFromData(dynamic data) { if (data == null) return null; diff --git a/lib/core/utils/sentry_logger.dart b/lib/core/utils/sentry_logger.dart index ced3fca..0db2456 100644 --- a/lib/core/utils/sentry_logger.dart +++ b/lib/core/utils/sentry_logger.dart @@ -45,9 +45,9 @@ class AppSentryLogger { scope.setTag('context', context); } if (extras != null) { - for (final entry in extras.entries) { - scope.setExtra(entry.key, entry.value); - } + // `setExtra` is deprecated in favour of structured Contexts, so the + // whole map goes in under one context key rather than as N extras. + scope.setContexts('extras', extras); } scope.level = _levelFor(exception); }, @@ -95,9 +95,9 @@ class AppSentryLogger { scope.setTag('context', context); } if (extras != null) { - for (final entry in extras.entries) { - scope.setExtra(entry.key, entry.value); - } + // `setExtra` is deprecated in favour of structured Contexts, so the + // whole map goes in under one context key rather than as N extras. + scope.setContexts('extras', extras); } }, ); diff --git a/lib/data/datasources/remote/auth_remote_source_mixin.dart b/lib/data/datasources/remote/auth_remote_source_mixin.dart index 97b59a7..dacf352 100644 --- a/lib/data/datasources/remote/auth_remote_source_mixin.dart +++ b/lib/data/datasources/remote/auth_remote_source_mixin.dart @@ -308,8 +308,8 @@ mixin AuthRemoteSourceMixin implements AuthRemoteSource { if (response.statusCode != 200) { final error = jsonDecode(response.body); throw AuthException( - message: error['error']?['message'] ?? - 'Failed to send verification email', + message: + error['error']?['message'] ?? 'Failed to send verification email', ); } } catch (e, stackTrace) { diff --git a/lib/data/datasources/remote/auth_remote_source_mobile.dart b/lib/data/datasources/remote/auth_remote_source_mobile.dart index 9397325..dbe6e77 100644 --- a/lib/data/datasources/remote/auth_remote_source_mobile.dart +++ b/lib/data/datasources/remote/auth_remote_source_mobile.dart @@ -20,7 +20,9 @@ import 'auth_remote_source_mixin.dart'; /// /// Uses [AuthInterceptor] (FlutterSecureStorage) for token storage and /// platform-native OAuth flows (GoogleSignIn, FlutterWebAuth2). -class AuthRemoteSourceImpl with AuthRemoteSourceMixin implements AuthRemoteSource { +class AuthRemoteSourceImpl + with AuthRemoteSourceMixin + implements AuthRemoteSource { GoogleSignIn? _googleSignIn; @override diff --git a/lib/data/datasources/remote/booking_json_parser.dart b/lib/data/datasources/remote/booking_json_parser.dart index 2d93118..ee5d2f3 100644 --- a/lib/data/datasources/remote/booking_json_parser.dart +++ b/lib/data/datasources/remote/booking_json_parser.dart @@ -40,10 +40,8 @@ Booking parseBookingJson(Map json) { consulteeName: json['consulteeName'] as String?, consulteeImage: json['consulteeImage'] as String?, slots: parseBookingSlots(json['slots']), - schedulingPeriodStartsAt: - parseDateTime(json['schedulingPeriodStartsAt']), - schedulingPeriodEndsAt: - parseDateTime(json['schedulingPeriodEndsAt']), + schedulingPeriodStartsAt: parseDateTime(json['schedulingPeriodStartsAt']), + schedulingPeriodEndsAt: parseDateTime(json['schedulingPeriodEndsAt']), schedulingTimezone: json['schedulingTimezone'] as String?, totalSessions: json['totalSessions'] as int?, sessionDurationInHours: @@ -53,9 +51,10 @@ Booking parseBookingJson(Map json) { ? CancellationReason.values.firstWhere( (e) => e.name == json['cancellationReason'] || - e.name == _camelCase( - json['cancellationReason'] as String, - ), + e.name == + _camelCase( + json['cancellationReason'] as String, + ), orElse: () => CancellationReason.other, ) : null, @@ -67,17 +66,16 @@ Booking parseBookingJson(Map json) { ? BookingSource.values.firstWhere( (e) => e.name == json['bookingSource'] || - e.name == _camelCase( - json['bookingSource'] as String, - ), + e.name == + _camelCase( + json['bookingSource'] as String, + ), orElse: () => BookingSource.requestSubmitted, ) : null, // Feedback fields - feedbackFromConsultee: - json['feedbackFromConsultee'] as String?, - feedbackFromConsultant: - json['feedbackFromConsultant'] as String?, + feedbackFromConsultee: json['feedbackFromConsultee'] as String?, + feedbackFromConsultant: json['feedbackFromConsultant'] as String?, rating: (json['rating'] as num?)?.toDouble(), // Participant info (for group programs) participants: parseBookingParticipants(json['participants']), @@ -88,15 +86,12 @@ Booking parseBookingJson(Map json) { planLevel: json['planLevel'] as String?, planPrerequisites: json['planPrerequisites'] as String?, planMaterialProvided: json['planMaterialProvided'] as String?, - planLearningOutcomes: - (json['planLearningOutcomes'] as List?) - ?.map((e) => e as String) - .toList() ?? - [], - planCertificateProvided: - json['planCertificateProvided'] as bool? ?? false, - planRecordingEnabled: - json['planRecordingEnabled'] as bool? ?? false, + planLearningOutcomes: (json['planLearningOutcomes'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + planCertificateProvided: json['planCertificateProvided'] as bool? ?? false, + planRecordingEnabled: json['planRecordingEnabled'] as bool? ?? false, meetingsPerWeek: json['meetingsPerWeek'] as int?, totalHours: (json['totalHours'] as num?)?.toDouble(), ); @@ -150,8 +145,5 @@ List parseBookingParticipants( String _camelCase(String input) { final parts = input.toLowerCase().split('_'); return parts.first + - parts - .skip(1) - .map((p) => p[0].toUpperCase() + p.substring(1)) - .join(); + parts.skip(1).map((p) => p[0].toUpperCase() + p.substring(1)).join(); } diff --git a/lib/data/datasources/remote/booking_remote_source.dart b/lib/data/datasources/remote/booking_remote_source.dart index 2fb0bbe..616d069 100644 --- a/lib/data/datasources/remote/booking_remote_source.dart +++ b/lib/data/datasources/remote/booking_remote_source.dart @@ -316,8 +316,7 @@ class BookingRemoteSourceImpl implements BookingRemoteSource { if (e.response?.statusCode == 400 && errorCode == 'MISSING_CONSULTEE_PROFILE') { throw ServerException( - message: - errorMessage ?? 'Please complete your profile before booking', + message: errorMessage ?? 'Please complete your profile before booking', statusCode: 400, errorCode: 'MISSING_CONSULTEE_PROFILE', ); @@ -495,8 +494,7 @@ class BookingRemoteSourceImpl implements BookingRemoteSource { /// Parse a booking from API response. /// Delegates to the shared [parseBookingJson] parser. - Booking _parseBooking(Map json) => - parseBookingJson(json); + Booking _parseBooking(Map json) => parseBookingJson(json); /// Parse bookings response with pagination BookingsResponse _parseBookingsResponse(Map json) { @@ -542,5 +540,4 @@ class BookingRemoteSourceImpl implements BookingRemoteSource { } return null; } - } diff --git a/lib/data/datasources/remote/collaborator_remote_source.dart b/lib/data/datasources/remote/collaborator_remote_source.dart index e447eb7..12183b7 100644 --- a/lib/data/datasources/remote/collaborator_remote_source.dart +++ b/lib/data/datasources/remote/collaborator_remote_source.dart @@ -86,7 +86,8 @@ class CollaboratorRemoteSourceImpl implements CollaboratorRemoteSource { throw e.error as AppException; } throw ServerException( - message: _extractErrorMessage(e) ?? 'Failed to respond to collaboration', + message: + _extractErrorMessage(e) ?? 'Failed to respond to collaboration', statusCode: e.response?.statusCode, originalError: e, ); diff --git a/lib/data/datasources/remote/dashboard_remote_source.dart b/lib/data/datasources/remote/dashboard_remote_source.dart index 570379f..c7b7cb2 100644 --- a/lib/data/datasources/remote/dashboard_remote_source.dart +++ b/lib/data/datasources/remote/dashboard_remote_source.dart @@ -83,17 +83,13 @@ class DashboardRemoteSourceImpl implements DashboardRemoteSource { final data = response.data as Map; return ConsultantDashboardStats( totalClients: data['totalClients'] as int? ?? 0, - totalSessionsConducted: - data['totalSessionsConducted'] as int? ?? 0, + totalSessionsConducted: data['totalSessionsConducted'] as int? ?? 0, upcomingSessions: data['upcomingSessions'] as int? ?? 0, pendingRequests: data['pendingRequests'] as int? ?? 0, - averageRating: - (data['averageRating'] as num?)?.toDouble() ?? 0.0, + averageRating: (data['averageRating'] as num?)?.toDouble() ?? 0.0, totalReviews: data['totalReviews'] as int? ?? 0, - totalEarnings: - (data['totalEarnings'] as num?)?.toDouble() ?? 0.0, - pendingEarnings: - (data['pendingEarnings'] as num?)?.toDouble() ?? 0.0, + totalEarnings: (data['totalEarnings'] as num?)?.toDouble() ?? 0.0, + pendingEarnings: (data['pendingEarnings'] as num?)?.toDouble() ?? 0.0, ); } @@ -213,8 +209,7 @@ class DashboardRemoteSourceImpl implements DashboardRemoteSource { final reviews = data['reviews'] as List? ?? []; return reviews.map((r) { final review = r as Map; - final consultee = - review['consulteeProfile'] as Map?; + final consultee = review['consulteeProfile'] as Map?; final user = consultee?['user'] as Map?; return Review( @@ -258,12 +253,9 @@ class DashboardRemoteSourceImpl implements DashboardRemoteSource { if (response.statusCode == 200) { final data = response.data as Map; return EarningsSummary( - totalEarnings: - (data['totalEarnings'] as num?)?.toDouble() ?? 0.0, - pendingEarnings: - (data['pendingEarnings'] as num?)?.toDouble() ?? 0.0, - paidEarnings: - (data['paidEarnings'] as num?)?.toDouble() ?? 0.0, + totalEarnings: (data['totalEarnings'] as num?)?.toDouble() ?? 0.0, + pendingEarnings: (data['pendingEarnings'] as num?)?.toDouble() ?? 0.0, + paidEarnings: (data['paidEarnings'] as num?)?.toDouble() ?? 0.0, currency: data['currency'] as String? ?? 'INR', ); } diff --git a/lib/data/datasources/remote/document_remote_source.dart b/lib/data/datasources/remote/document_remote_source.dart index 305fcb8..7ae353a 100644 --- a/lib/data/datasources/remote/document_remote_source.dart +++ b/lib/data/datasources/remote/document_remote_source.dart @@ -53,8 +53,7 @@ class DocumentRemoteSourceImpl implements DocumentRemoteSource { ); final data = response.data['data'] as List; return data - .map((d) => AppointmentDocument.fromJson( - d as Map)) + .map((d) => AppointmentDocument.fromJson(d as Map)) .toList(); } on DioException catch (e) { throw ServerException( diff --git a/lib/data/datasources/remote/payout_remote_source.dart b/lib/data/datasources/remote/payout_remote_source.dart index 8e787d3..fa545c2 100644 --- a/lib/data/datasources/remote/payout_remote_source.dart +++ b/lib/data/datasources/remote/payout_remote_source.dart @@ -33,13 +33,12 @@ class PayoutRemoteSourceImpl implements PayoutRemoteSource { ); final data = response.data['data'] as List; return data - .map((d) => - PayoutAccount.fromJson(d as Map)) + .map((d) => PayoutAccount.fromJson(d as Map)) .toList(); } on DioException catch (e) { throw ServerException( - message: e.response?.data?['error']?['message'] ?? - 'Failed to load accounts', + message: + e.response?.data?['error']?['message'] ?? 'Failed to load accounts', ); } } @@ -105,8 +104,8 @@ class PayoutRemoteSourceImpl implements PayoutRemoteSource { ); } on DioException catch (e) { throw ServerException( - message: e.response?.data?['error']?['message'] ?? - 'Failed to set default', + message: + e.response?.data?['error']?['message'] ?? 'Failed to set default', ); } } diff --git a/lib/data/datasources/remote/referral_remote_source.dart b/lib/data/datasources/remote/referral_remote_source.dart index d73f49c..b265a7d 100644 --- a/lib/data/datasources/remote/referral_remote_source.dart +++ b/lib/data/datasources/remote/referral_remote_source.dart @@ -152,8 +152,7 @@ class ReferralRemoteSourceImpl implements ReferralRemoteSource { code: json['code'] as String, customCode: json['customCode'] as String?, totalReferrals: (json['totalReferrals'] as num?)?.toInt() ?? 0, - successfulReferrals: - (json['successfulReferrals'] as num?)?.toInt() ?? 0, + successfulReferrals: (json['successfulReferrals'] as num?)?.toInt() ?? 0, totalEarned: (json['totalEarned'] as num?)?.toInt() ?? 0, maxReferrals: (json['maxReferrals'] as num?)?.toInt(), isActive: json['isActive'] as bool? ?? true, diff --git a/lib/data/datasources/remote/trial_remote_source.dart b/lib/data/datasources/remote/trial_remote_source.dart index fc3ccec..93f4c6a 100644 --- a/lib/data/datasources/remote/trial_remote_source.dart +++ b/lib/data/datasources/remote/trial_remote_source.dart @@ -56,8 +56,8 @@ class TrialRemoteSourceImpl implements TrialRemoteSource { }).toList(); } on DioException catch (e) { throw ServerException( - message: e.response?.data?['error']?['message'] ?? - 'Failed to load trials', + message: + e.response?.data?['error']?['message'] ?? 'Failed to load trials', ); } } @@ -82,8 +82,8 @@ class TrialRemoteSourceImpl implements TrialRemoteSource { ); } on DioException catch (e) { throw ServerException( - message: e.response?.data?['error']?['message'] ?? - 'Failed to request trial', + message: + e.response?.data?['error']?['message'] ?? 'Failed to request trial', ); } } @@ -97,8 +97,8 @@ class TrialRemoteSourceImpl implements TrialRemoteSource { ); } on DioException catch (e) { throw ServerException( - message: e.response?.data?['error']?['message'] ?? - 'Failed to load trial', + message: + e.response?.data?['error']?['message'] ?? 'Failed to load trial', ); } } @@ -118,8 +118,8 @@ class TrialRemoteSourceImpl implements TrialRemoteSource { ); } on DioException catch (e) { throw ServerException( - message: e.response?.data?['error']?['message'] ?? - 'Failed to update trial', + message: + e.response?.data?['error']?['message'] ?? 'Failed to update trial', ); } } @@ -153,8 +153,8 @@ class TrialRemoteSourceImpl implements TrialRemoteSource { ); } on DioException catch (e) { throw ServerException( - message: e.response?.data?['error']?['message'] ?? - 'Failed to load stats', + message: + e.response?.data?['error']?['message'] ?? 'Failed to load stats', ); } } diff --git a/lib/data/datasources/remote/verification_remote_source.dart b/lib/data/datasources/remote/verification_remote_source.dart index 9cdfd22..e5299fb 100644 --- a/lib/data/datasources/remote/verification_remote_source.dart +++ b/lib/data/datasources/remote/verification_remote_source.dart @@ -102,8 +102,7 @@ class VerificationRemoteSourceImpl implements VerificationRemoteSource { final response = await _dio.get('/api/verification/documents'); final data = response.data['data'] as List; return data - .map((d) => - VerificationDocument.fromJson(d as Map)) + .map((d) => VerificationDocument.fromJson(d as Map)) .toList(); } on DioException catch (e) { throw ServerException( diff --git a/lib/data/datasources/remote/waitlist_remote_source.dart b/lib/data/datasources/remote/waitlist_remote_source.dart index 1f61824..4d3f7b8 100644 --- a/lib/data/datasources/remote/waitlist_remote_source.dart +++ b/lib/data/datasources/remote/waitlist_remote_source.dart @@ -38,8 +38,8 @@ class WaitlistRemoteSourceImpl implements WaitlistRemoteSource { .toList(); } on DioException catch (e) { throw ServerException( - message: e.response?.data?['error']?['message'] ?? - 'Failed to load waitlist', + message: + e.response?.data?['error']?['message'] ?? 'Failed to load waitlist', ); } } @@ -62,8 +62,8 @@ class WaitlistRemoteSourceImpl implements WaitlistRemoteSource { ); } on DioException catch (e) { throw ServerException( - message: e.response?.data?['error']?['message'] ?? - 'Failed to join waitlist', + message: + e.response?.data?['error']?['message'] ?? 'Failed to join waitlist', ); } } @@ -77,8 +77,8 @@ class WaitlistRemoteSourceImpl implements WaitlistRemoteSource { ); } on DioException catch (e) { throw ServerException( - message: e.response?.data?['error']?['message'] ?? - 'Failed to load entry', + message: + e.response?.data?['error']?['message'] ?? 'Failed to load entry', ); } } diff --git a/lib/data/models/explore/consultant_details_model.dart b/lib/data/models/explore/consultant_details_model.dart index 376488d..322b98e 100644 --- a/lib/data/models/explore/consultant_details_model.dart +++ b/lib/data/models/explore/consultant_details_model.dart @@ -105,7 +105,8 @@ class ConsultantDetailsModel with _$ConsultantDetailsModel { ConsultantVerificationStatus? _parseVerificationStatus(String? value) { if (value == null) return null; return switch (value.toUpperCase()) { - 'PENDING_VERIFICATION' => ConsultantVerificationStatus.pendingVerification, + 'PENDING_VERIFICATION' => + ConsultantVerificationStatus.pendingVerification, 'UNDER_REVIEW' => ConsultantVerificationStatus.underReview, 'VERIFIED' => ConsultantVerificationStatus.verified, 'REJECTED' => ConsultantVerificationStatus.rejected, diff --git a/lib/data/models/explore/consultant_model.dart b/lib/data/models/explore/consultant_model.dart index 43a6570..b995309 100644 --- a/lib/data/models/explore/consultant_model.dart +++ b/lib/data/models/explore/consultant_model.dart @@ -67,7 +67,8 @@ class ConsultantModel with _$ConsultantModel { ConsultantVerificationStatus? _parseVerificationStatus(String? value) { if (value == null) return null; return switch (value.toUpperCase()) { - 'PENDING_VERIFICATION' => ConsultantVerificationStatus.pendingVerification, + 'PENDING_VERIFICATION' => + ConsultantVerificationStatus.pendingVerification, 'UNDER_REVIEW' => ConsultantVerificationStatus.underReview, 'VERIFIED' => ConsultantVerificationStatus.verified, 'REJECTED' => ConsultantVerificationStatus.rejected, diff --git a/lib/data/models/user_model.dart b/lib/data/models/user_model.dart index 9c88c3c..9d84cbe 100644 --- a/lib/data/models/user_model.dart +++ b/lib/data/models/user_model.dart @@ -1,3 +1,9 @@ +// @JsonKey on a freezed constructor parameter is the documented pattern for +// mapping snake_case API fields, but it trips this lint (the annotation lands +// on the generated field, not the parameter). Suppressed file-wide, matching +// what the Prisma client generator emits for the same reason. +// ignore_for_file: invalid_annotation_target + import 'package:freezed_annotation/freezed_annotation.dart'; import '../../core/constants/enums.dart'; diff --git a/lib/data/repositories/auth_repository_impl.dart b/lib/data/repositories/auth_repository_impl.dart index 8da8bf1..93f141b 100644 --- a/lib/data/repositories/auth_repository_impl.dart +++ b/lib/data/repositories/auth_repository_impl.dart @@ -346,7 +346,9 @@ class AuthRepositoryImpl implements AuthRepository { if (timezone != null) data['timezone'] = timezone; if (image != null) data['image'] = image; if (bio != null) data['bio'] = bio; - if (dateOfBirth != null) data['dateOfBirth'] = dateOfBirth.toIso8601String(); + if (dateOfBirth != null) { + data['dateOfBirth'] = dateOfBirth.toIso8601String(); + } if (gender != null) data['gender'] = gender; if (city != null) data['city'] = city; if (country != null) data['country'] = country; diff --git a/lib/data/repositories/booking_repository_impl.dart b/lib/data/repositories/booking_repository_impl.dart index 4af5681..6fc8601 100644 --- a/lib/data/repositories/booking_repository_impl.dart +++ b/lib/data/repositories/booking_repository_impl.dart @@ -131,24 +131,20 @@ class BookingRepositoryImpl implements BookingRepository { final userId = booking.consultantUserId!; if (!consultantsMap.containsKey(userId)) { - consultantsMap[userId] = AppointmentConsultant.fromBooking(booking) - .copyWith( - allBookingTypes: - booking.bookingType != null ? [booking.bookingType!] : [], + consultantsMap[userId] = + AppointmentConsultant.fromBooking(booking).copyWith( + allBookingTypes: [booking.bookingType], ); } else { final existing = consultantsMap[userId]!; final types = {...existing.allBookingTypes}; - if (booking.bookingType != null) { - types.add(booking.bookingType!); - } + types.add(booking.bookingType); if (booking.createdAt != null && (existing.lastAppointmentDate == null || booking.createdAt!.isAfter(existing.lastAppointmentDate!))) { - consultantsMap[userId] = - AppointmentConsultant.fromBooking(booking) - .copyWith(allBookingTypes: types.toList()); + consultantsMap[userId] = AppointmentConsultant.fromBooking(booking) + .copyWith(allBookingTypes: types.toList()); } else { consultantsMap[userId] = existing.copyWith(allBookingTypes: types.toList()); @@ -206,7 +202,8 @@ class BookingRepositoryImpl implements BookingRepository { } else { // 1:1 bookings keyed by client user ID. // For consultant-view bookings, the client info is in consultee fields. - final clientUserId = booking.consulteeUserId ?? booking.consultantUserId; + final clientUserId = + booking.consulteeUserId ?? booking.consultantUserId; if (clientUserId == null) continue; if (!clientsMap.containsKey(clientUserId)) { diff --git a/lib/domain/entities/onboarding/onboarding_state.dart b/lib/domain/entities/onboarding/onboarding_state.dart index 9f6004e..523f417 100644 --- a/lib/domain/entities/onboarding/onboarding_state.dart +++ b/lib/domain/entities/onboarding/onboarding_state.dart @@ -59,8 +59,7 @@ class OnboardingState with _$OnboardingState { /// Total number of steps (varies by role). /// Consultee: 0=Role, 1=Personal, 2=Profile, 3=Preferences, 4=Agreement, 5=Review (6 steps) /// Consultant: 0=Role, 1=Personal, 2=Profile, 3=Background, 4=Availability, 5=Agreement, 6=Review (7 steps) - int get totalSteps => - selectedRole == UserRole.consultant ? 7 : 6; + int get totalSteps => selectedRole == UserRole.consultant ? 7 : 6; /// Progress as a fraction (0.0 to 1.0) double get progress => (currentStep + 1) / totalSteps; diff --git a/lib/features/announcements/providers/announcement_provider.dart b/lib/features/announcements/providers/announcement_provider.dart index e546e3c..5b8361e 100644 --- a/lib/features/announcements/providers/announcement_provider.dart +++ b/lib/features/announcements/providers/announcement_provider.dart @@ -1,4 +1,3 @@ -import 'package:dio/dio.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../domain/entities/announcement/announcement_entity.dart'; diff --git a/lib/features/announcements/widgets/announcement_banner.dart b/lib/features/announcements/widgets/announcement_banner.dart index eaf6618..6fbfdb3 100644 --- a/lib/features/announcements/widgets/announcement_banner.dart +++ b/lib/features/announcements/widgets/announcement_banner.dart @@ -9,12 +9,10 @@ class AnnouncementBanner extends ConsumerStatefulWidget { const AnnouncementBanner({super.key}); @override - ConsumerState createState() => - _AnnouncementBannerState(); + ConsumerState createState() => _AnnouncementBannerState(); } -class _AnnouncementBannerState - extends ConsumerState { +class _AnnouncementBannerState extends ConsumerState { final _dismissed = {}; @override @@ -23,9 +21,8 @@ class _AnnouncementBannerState return announcementsAsync.when( data: (announcements) { - final visible = announcements - .where((a) => !_dismissed.contains(a.id)) - .toList(); + final visible = + announcements.where((a) => !_dismissed.contains(a.id)).toList(); if (visible.isEmpty) return const SizedBox.shrink(); final announcement = visible.first; @@ -64,14 +61,12 @@ class _AnnouncementBannerState actions: [ if (announcement.linkUrl != null) TextButton( - onPressed: () => - launchUrl(Uri.parse(announcement.linkUrl!)), + onPressed: () => launchUrl(Uri.parse(announcement.linkUrl!)), child: Text(announcement.linkText ?? 'Learn more'), ), IconButton( icon: const Icon(Icons.close, size: 18), - onPressed: () => - setState(() => _dismissed.add(announcement.id)), + onPressed: () => setState(() => _dismissed.add(announcement.id)), ), ], ); diff --git a/lib/features/auth/screens/reset_password_screen.dart b/lib/features/auth/screens/reset_password_screen.dart index 12b31d6..3398939 100644 --- a/lib/features/auth/screens/reset_password_screen.dart +++ b/lib/features/auth/screens/reset_password_screen.dart @@ -20,8 +20,7 @@ class ResetPasswordScreen extends ConsumerStatefulWidget { _ResetPasswordScreenState(); } -class _ResetPasswordScreenState - extends ConsumerState { +class _ResetPasswordScreenState extends ConsumerState { final _formKey = GlobalKey(); final _passwordController = TextEditingController(); final _confirmPasswordController = TextEditingController(); @@ -94,8 +93,7 @@ class _ResetPasswordScreenState Text( 'Enter your new password below.', style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface - .withValues(alpha: 0.7), + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), @@ -112,9 +110,7 @@ class _ResetPasswordScreenState prefixIcon: const Icon(Icons.lock_outlined), suffixIcon: IconButton( icon: Icon( - _obscurePassword - ? Icons.visibility_off - : Icons.visibility, + _obscurePassword ? Icons.visibility_off : Icons.visibility, ), onPressed: () => setState( () => _obscurePassword = !_obscurePassword, @@ -144,9 +140,7 @@ class _ResetPasswordScreenState prefixIcon: const Icon(Icons.lock_outlined), suffixIcon: IconButton( icon: Icon( - _obscureConfirm - ? Icons.visibility_off - : Icons.visibility, + _obscureConfirm ? Icons.visibility_off : Icons.visibility, ), onPressed: () => setState( () => _obscureConfirm = !_obscureConfirm, @@ -165,8 +159,7 @@ class _ResetPasswordScreenState // Submit button LoadingButton( - onPressed: - _isLoading ? null : _handleResetPassword, + onPressed: _isLoading ? null : _handleResetPassword, isLoading: _isLoading, child: const Text('Reset Password'), ), @@ -207,8 +200,7 @@ class _ResetPasswordScreenState Text( 'You can now sign in with your new password.', style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface - .withValues(alpha: 0.7), + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), @@ -228,9 +220,7 @@ class _ResetPasswordScreenState setState(() => _isLoading = true); - final success = await ref - .read(authProvider.notifier) - .resetPassword( + final success = await ref.read(authProvider.notifier).resetPassword( token: widget.token, newPassword: _passwordController.text, ); diff --git a/lib/features/auth/screens/sign_up_screen.dart b/lib/features/auth/screens/sign_up_screen.dart index 928c340..92f5f1c 100644 --- a/lib/features/auth/screens/sign_up_screen.dart +++ b/lib/features/auth/screens/sign_up_screen.dart @@ -58,7 +58,8 @@ class _SignUpScreenState extends ConsumerState { setState(() => _loadingSocialProvider = null); } // Fire-and-forget referral code application on successful signup - if (next.isAuthenticated && _referralCodeController.text.trim().isNotEmpty) { + if (next.isAuthenticated && + _referralCodeController.text.trim().isNotEmpty) { final code = _referralCodeController.text.trim(); ref.read(referralRepositoryProvider).applyReferralCode(code); } diff --git a/lib/features/booking/providers/my_bookings_provider.dart b/lib/features/booking/providers/my_bookings_provider.dart index c1fe739..afa7c74 100644 --- a/lib/features/booking/providers/my_bookings_provider.dart +++ b/lib/features/booking/providers/my_bookings_provider.dart @@ -20,8 +20,7 @@ class MyBookings extends _$MyBookings { Future> _fetchBookings() async { final repository = ref.read(bookingRepositoryProvider); final user = ref.read(currentUserProvider); - final role = - user?.role == UserRole.consultant ? 'consultant' : null; + final role = user?.role == UserRole.consultant ? 'consultant' : null; final response = await repository.getMyBookings( role: role, ); diff --git a/lib/features/booking/screens/appointment_documents_screen.dart b/lib/features/booking/screens/appointment_documents_screen.dart index 2d05091..862b6d0 100644 --- a/lib/features/booking/screens/appointment_documents_screen.dart +++ b/lib/features/booking/screens/appointment_documents_screen.dart @@ -43,8 +43,7 @@ class AppointmentDocumentsScreen extends ConsumerWidget { ), ); }, - loading: () => - const Center(child: CircularProgressIndicator()), + loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error: $e')), ), ); @@ -106,8 +105,7 @@ class _DocumentCard extends StatelessWidget { ), if (document.description != null) ...[ const SizedBox(height: 8), - Text(document.description!, - style: theme.textTheme.bodyMedium), + Text(document.description!, style: theme.textTheme.bodyMedium), ], if (document.reviewNotes != null) ...[ const SizedBox(height: 8), @@ -154,15 +152,10 @@ class _DocumentCard extends StatelessWidget { DocumentReviewStatus status, ) => switch (status) { - DocumentReviewStatus.pending => - (Colors.orange, Icons.hourglass_top), - DocumentReviewStatus.inReview => - (Colors.blue, Icons.rate_review), - DocumentReviewStatus.approved => - (Colors.green, Icons.check_circle), - DocumentReviewStatus.rejected => - (Colors.red, Icons.cancel), - DocumentReviewStatus.needsRevision => - (Colors.amber, Icons.edit_note), + DocumentReviewStatus.pending => (Colors.orange, Icons.hourglass_top), + DocumentReviewStatus.inReview => (Colors.blue, Icons.rate_review), + DocumentReviewStatus.approved => (Colors.green, Icons.check_circle), + DocumentReviewStatus.rejected => (Colors.red, Icons.cancel), + DocumentReviewStatus.needsRevision => (Colors.amber, Icons.edit_note), }; } diff --git a/lib/features/booking/screens/booking_screen.dart b/lib/features/booking/screens/booking_screen.dart index b3ac23f..552e004 100644 --- a/lib/features/booking/screens/booking_screen.dart +++ b/lib/features/booking/screens/booking_screen.dart @@ -46,7 +46,7 @@ class _BookingScreenState extends ConsumerState { // For subscription bookings DateTime? _periodStartDate; // Note: End date is calculated based on plan duration - int _planDurationMonths = 1; // Default, will be updated from plan data + final int _planDurationMonths = 1; // Default, will be updated from plan data @override void initState() { @@ -218,8 +218,7 @@ class _BookingScreenState extends ConsumerState { selectedDate: _selectedDate, days: days, selectedSlot: _selectedSlot, - onSlotSelected: (slot) => - setState(() => _selectedSlot = slot), + onSlotSelected: (slot) => setState(() => _selectedSlot = slot), ), loading: () => const Center( child: Padding( @@ -331,8 +330,7 @@ class _BookingScreenState extends ConsumerState { BookingDatePickerCard( date: _periodStartDate, placeholder: 'Select start date', - onDateSelected: (date) => - setState(() => _periodStartDate = date), + onDateSelected: (date) => setState(() => _periodStartDate = date), ), const SizedBox(height: 16), @@ -497,8 +495,9 @@ class _BookingScreenState extends ConsumerState { consultantProfileId: widget.consultantId, planId: widget.planId, slotStartTimes: [_selectedSlot!.startsAt], - message: - _messageController.text.isEmpty ? null : _messageController.text, + message: _messageController.text.isEmpty + ? null + : _messageController.text, ); return; } @@ -544,8 +543,9 @@ class _BookingScreenState extends ConsumerState { consultantProfileId: widget.consultantId, planId: widget.planId, schedulingPeriodStart: _periodStartDate!, - message: - _messageController.text.isEmpty ? null : _messageController.text, + message: _messageController.text.isEmpty + ? null + : _messageController.text, ); return; } diff --git a/lib/features/booking/screens/my_booking_details_screen.dart b/lib/features/booking/screens/my_booking_details_screen.dart index 723ceb3..bd63e4e 100644 --- a/lib/features/booking/screens/my_booking_details_screen.dart +++ b/lib/features/booking/screens/my_booking_details_screen.dart @@ -255,8 +255,7 @@ class _MyBookingDetailsScreenState ], // Message - if (booking.message != null && - booking.message!.isNotEmpty) ...[ + if (booking.message != null && booking.message!.isNotEmpty) ...[ const SizedBox(height: 24), _buildSectionLabel( _isConsultantView ? "Client's Message" : 'Your Message', @@ -456,6 +455,9 @@ class _MyBookingDetailsScreenState booking: _fetchedBooking!, ); if (choice == null) return; + // The sheet above awaited, so this State may have been disposed before we + // reuse `context` for the next sheet. + if (!mounted) return; if (choice is RescheduleSession) { // Show session selector diff --git a/lib/features/booking/screens/my_bookings_screen.dart b/lib/features/booking/screens/my_bookings_screen.dart index 2cd378d..ca0db80 100644 --- a/lib/features/booking/screens/my_bookings_screen.dart +++ b/lib/features/booking/screens/my_bookings_screen.dart @@ -365,9 +365,8 @@ class _MyBookingsScreenState extends ConsumerState final subscriptions = allBookings .where((b) => b.bookingType == BookingType.subscription) .toList(); - final freeTrials = allBookings - .where((b) => b.bookingType == BookingType.trial) - .toList(); + final freeTrials = + allBookings.where((b) => b.bookingType == BookingType.trial).toList(); return ListView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 32), @@ -439,13 +438,13 @@ class _MyBookingsScreenState extends ConsumerState }, onPayNow: FeatureFlags.payments && booking.status == RequestStatus.approvedPendingPayment - ? () { - context.pushNamed( - 'checkout', - extra: booking, - ); - } - : null, + ? () { + context.pushNamed( + 'checkout', + extra: booking, + ); + } + : null, ), ), ], @@ -476,7 +475,7 @@ class _MyBookingsScreenState extends ConsumerState ); }, onPayNow: FeatureFlags.payments && - booking.status == RequestStatus.approvedPendingPayment + booking.status == RequestStatus.approvedPendingPayment ? () { context.pushNamed( 'checkout', diff --git a/lib/features/booking/widgets/booking_action_buttons.dart b/lib/features/booking/widgets/booking_action_buttons.dart index 1e661ba..4978529 100644 --- a/lib/features/booking/widgets/booking_action_buttons.dart +++ b/lib/features/booking/widgets/booking_action_buttons.dart @@ -57,9 +57,8 @@ class BookingActionButtons extends StatelessWidget { } // Chat button - final chatUserId = isConsultantView - ? booking.consulteeUserId - : booking.consultantUserId; + final chatUserId = + isConsultantView ? booking.consulteeUserId : booking.consultantUserId; if (chatUserId != null && booking.status != RequestStatus.cancelled && booking.status != RequestStatus.rejected && @@ -70,8 +69,7 @@ class BookingActionButtons extends StatelessWidget { child: FilledButton.icon( onPressed: isActionLoading ? null : onTalkToExpert, icon: const Icon(Icons.chat_bubble_outline), - label: Text( - isConsultantView ? 'Message Client' : 'Talk to Expert'), + label: Text(isConsultantView ? 'Message Client' : 'Talk to Expert'), style: FilledButton.styleFrom( backgroundColor: theme.colorScheme.primary, padding: const EdgeInsets.symmetric(vertical: 16), diff --git a/lib/features/booking/widgets/booking_card.dart b/lib/features/booking/widgets/booking_card.dart index d5663b0..9d8dfe5 100644 --- a/lib/features/booking/widgets/booking_card.dart +++ b/lib/features/booking/widgets/booking_card.dart @@ -26,12 +26,12 @@ class BookingCard extends StatelessWidget { color: colorScheme.surface, borderRadius: BorderRadius.circular(16), border: Border.all( - color: colorScheme.outlineVariant.withOpacity(0.5), + color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1, ), boxShadow: [ BoxShadow( - color: colorScheme.shadow.withOpacity(0.04), + color: colorScheme.shadow.withValues(alpha: 0.04), blurRadius: 8, offset: const Offset(0, 2), ), @@ -93,7 +93,7 @@ class BookingCard extends StatelessWidget { // Divider Container( height: 1, - color: colorScheme.outlineVariant.withOpacity(0.3), + color: colorScheme.outlineVariant.withValues(alpha: 0.3), ), const SizedBox(height: 12), @@ -127,8 +127,8 @@ class BookingCard extends StatelessWidget { Text( 'Requested ${_formatRelativeDate(booking.createdAt!)}', style: theme.textTheme.bodySmall?.copyWith( - color: - colorScheme.onSurfaceVariant.withOpacity(0.7), + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), ), ), _buildPriceTag(theme), @@ -215,8 +215,8 @@ class BookingCard extends StatelessWidget { final avatarSize = 32.0; final overlap = 10.0; final count = participants.length; - final totalWidth = - avatarSize + (count - 1) * (avatarSize - overlap) + + final totalWidth = avatarSize + + (count - 1) * (avatarSize - overlap) + (remaining > 0 ? avatarSize - overlap : 0); return SizedBox( @@ -431,28 +431,28 @@ class BookingCard extends StatelessWidget { return ( Icons.videocam_outlined, 'Consultation', - colorScheme.secondaryContainer.withOpacity(0.5), + colorScheme.secondaryContainer.withValues(alpha: 0.5), colorScheme.onSecondaryContainer, ); case BookingType.subscription: return ( Icons.repeat, 'Subscription', - colorScheme.tertiaryContainer.withOpacity(0.5), + colorScheme.tertiaryContainer.withValues(alpha: 0.5), colorScheme.onTertiaryContainer, ); case BookingType.webinar: return ( Icons.groups_outlined, 'Webinar', - colorScheme.primaryContainer.withOpacity(0.5), + colorScheme.primaryContainer.withValues(alpha: 0.5), colorScheme.onPrimaryContainer, ); case BookingType.classes: return ( Icons.school_outlined, 'Class', - const Color(0xFFE8EAF6).withOpacity(0.7), // Indigo container + const Color(0xFFE8EAF6).withValues(alpha: 0.7), // Indigo container const Color(0xFF3949AB), // Indigo accent ); case BookingType.trial: diff --git a/lib/features/booking/widgets/booking_detail_sections.dart b/lib/features/booking/widgets/booking_detail_sections.dart index 9af8f46..f6f2c7a 100644 --- a/lib/features/booking/widgets/booking_detail_sections.dart +++ b/lib/features/booking/widgets/booking_detail_sections.dart @@ -145,8 +145,18 @@ class BookingSchedulingPeriod extends StatelessWidget { String _formatDate(DateTime date) { const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', ]; return '${months[date.month - 1]} ${date.day}, ${date.year}'; } @@ -252,8 +262,18 @@ class BookingCancellationBanner extends StatelessWidget { String _formatDate(DateTime date) { const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', ]; return '${months[date.month - 1]} ${date.day}, ${date.year}'; } @@ -372,11 +392,9 @@ class BookingPlanExtras extends StatelessWidget { if (booking.planLanguage != null) _chip(context, Icons.language, booking.planLanguage!), if (booking.planLevel != null) - _chip( - context, Icons.signal_cellular_alt, booking.planLevel!), + _chip(context, Icons.signal_cellular_alt, booking.planLevel!), if (booking.planCertificateProvided) - _chip( - context, Icons.workspace_premium_rounded, 'Certificate'), + _chip(context, Icons.workspace_premium_rounded, 'Certificate'), if (booking.planRecordingEnabled) _chip(context, Icons.fiber_manual_record_rounded, 'Recorded'), ], diff --git a/lib/features/booking/widgets/booking_group_hero.dart b/lib/features/booking/widgets/booking_group_hero.dart index 566e46d..49d6f8d 100644 --- a/lib/features/booking/widgets/booking_group_hero.dart +++ b/lib/features/booking/widgets/booking_group_hero.dart @@ -107,11 +107,9 @@ class BookingGroupHero extends StatelessWidget { if (booking.planLanguage != null) _chip(context, Icons.language, booking.planLanguage!), if (booking.planLevel != null) - _chip( - context, Icons.signal_cellular_alt, booking.planLevel!), + _chip(context, Icons.signal_cellular_alt, booking.planLevel!), if (booking.planCertificateProvided) - _chip( - context, Icons.workspace_premium_rounded, 'Certificate'), + _chip(context, Icons.workspace_premium_rounded, 'Certificate'), if (booking.planRecordingEnabled) _chip(context, Icons.fiber_manual_record_rounded, 'Recorded'), if (!isWebinar && booking.meetingsPerWeek != null) diff --git a/lib/features/booking/widgets/booking_plan_info_card.dart b/lib/features/booking/widgets/booking_plan_info_card.dart index efd8e91..30c4e7f 100644 --- a/lib/features/booking/widgets/booking_plan_info_card.dart +++ b/lib/features/booking/widgets/booking_plan_info_card.dart @@ -45,8 +45,7 @@ class BookingPlanInfoCard extends StatelessWidget { ), ), Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(8), diff --git a/lib/features/booking/widgets/cancel_dialog.dart b/lib/features/booking/widgets/cancel_dialog.dart index 07fd914..6fbdb6e 100644 --- a/lib/features/booking/widgets/cancel_dialog.dart +++ b/lib/features/booking/widgets/cancel_dialog.dart @@ -150,8 +150,7 @@ class _CancelDialogState extends State<_CancelDialog> { return FilterChip( label: Text(_reasonLabel(reason)), selected: isSelected, - onSelected: (_) => - setState(() => _selectedReason = reason), + onSelected: (_) => setState(() => _selectedReason = reason), selectedColor: theme.colorScheme.errorContainer, checkmarkColor: theme.colorScheme.onErrorContainer, ); diff --git a/lib/features/booking/widgets/reschedule_dialog.dart b/lib/features/booking/widgets/reschedule_dialog.dart index 87ad98d..5e6b716 100644 --- a/lib/features/booking/widgets/reschedule_dialog.dart +++ b/lib/features/booking/widgets/reschedule_dialog.dart @@ -197,7 +197,7 @@ class _OptionCard extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: iconColor.withOpacity(0.1), + color: iconColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon(icon, color: iconColor), diff --git a/lib/features/chat/providers/chat_service_provider.dart b/lib/features/chat/providers/chat_service_provider.dart index 030ca54..57010d1 100644 --- a/lib/features/chat/providers/chat_service_provider.dart +++ b/lib/features/chat/providers/chat_service_provider.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - import '../../../core/constants/enums.dart'; import '../../../core/utils/sentry_logger.dart'; import '../../../data/repositories/booking_repository_impl.dart'; diff --git a/lib/features/chat/screens/chat_list_screen.dart b/lib/features/chat/screens/chat_list_screen.dart index 92fb201..9ac68f8 100644 --- a/lib/features/chat/screens/chat_list_screen.dart +++ b/lib/features/chat/screens/chat_list_screen.dart @@ -126,9 +126,8 @@ class _ChatListScreenState extends ConsumerState { final consultantsAsync = ref.watch(appointmentConsultantsProvider); final user = ref.watch(currentUserProvider); final isConsultant = user?.role == UserRole.consultant; - final subtitle = isConsultant - ? 'Chat with your clients' - : 'Chat with your consultants'; + final subtitle = + isConsultant ? 'Chat with your clients' : 'Chat with your consultants'; return Scaffold( backgroundColor: colorScheme.surfaceContainerLowest, @@ -296,8 +295,8 @@ class _ChatListScreenState extends ConsumerState { // Apply search filter if (_searchQuery.isNotEmpty) { dmConsultants = dmConsultants - .where((c) => - c.consultantName.toLowerCase().contains(_searchQuery)) + .where( + (c) => c.consultantName.toLowerCase().contains(_searchQuery)) .toList(); } @@ -358,8 +357,7 @@ class _ChatListScreenState extends ConsumerState { else ...dmConsultants.map((consultant) => _ConsultantTile( consultant: consultant, - isLoading: - _navigatingKey == _consultantKey(consultant), + isLoading: _navigatingKey == _consultantKey(consultant), onTap: () => _onConsultantTap(consultant), )), ], @@ -455,8 +453,7 @@ class _ChatListScreenState extends ConsumerState { ), ...eventConsultants.map((consultant) => _EventChannelTile( consultant: consultant, - isLoading: - _navigatingKey == _consultantKey(consultant), + isLoading: _navigatingKey == _consultantKey(consultant), onTap: () => _onConsultantTap(consultant), chatService: ref.read(chatServiceProvider.notifier), )), @@ -468,7 +465,6 @@ class _ChatListScreenState extends ConsumerState { error: (error, _) => const SliverToBoxAdapter(child: SizedBox.shrink()), ); } - } /// Tile widget for displaying a consultant in the linked consultants list @@ -744,8 +740,7 @@ class _EventChannelTileState extends State<_EventChannelTile> { child: CircleAvatar( radius: 10, backgroundColor: colorScheme.primaryContainer, - backgroundImage: - hasImage ? NetworkImage(imageUrl) : null, + backgroundImage: hasImage ? NetworkImage(imageUrl) : null, child: hasImage ? null : Text( diff --git a/lib/features/chat/screens/chat_room_screen.dart b/lib/features/chat/screens/chat_room_screen.dart index b9a391b..22fa9f1 100644 --- a/lib/features/chat/screens/chat_room_screen.dart +++ b/lib/features/chat/screens/chat_room_screen.dart @@ -133,7 +133,8 @@ class _ChatRoomScreenState extends ConsumerState { ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to update notification settings')), + const SnackBar( + content: Text('Failed to update notification settings')), ); } } @@ -281,154 +282,154 @@ class _ChatRoomScreenState extends ConsumerState { return StreamChannel( channel: _channel!, child: Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.pop(), + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + actions: [ + IconButton( + icon: const Icon(Icons.more_vert), + onPressed: _showChatActionsSheet, ), - actions: [ - IconButton( - icon: const Icon(Icons.more_vert), - onPressed: _showChatActionsSheet, - ), - ], - title: GestureDetector( - onTap: isGroupChannel - ? () => showChannelMembersSheet( - context: context, - channel: _channel!, - ) - : null, - child: Row( - children: [ - _buildAppBarAvatar( - theme, - colorScheme, - isGroupChannel, - otherMember, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + ], + title: GestureDetector( + onTap: isGroupChannel + ? () => showChannelMembersSheet( + context: context, + channel: _channel!, + ) + : null, + child: Row( + children: [ + _buildAppBarAvatar( + theme, + colorScheme, + isGroupChannel, + otherMember, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (isArchived) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + Icons.archive_outlined, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + ), + Expanded( + child: Text( + displayName, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (subtitle != null) Row( children: [ - if (isArchived) - Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - Icons.archive_outlined, - size: 14, - color: colorScheme.onSurfaceVariant, - ), - ), - Expanded( - child: Text( - displayName, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Text( + subtitle, + style: theme.textTheme.labelSmall?.copyWith( + color: subtitle == 'Online' + ? Colors.green + : colorScheme.onSurfaceVariant, ), ), - ], - ), - if (subtitle != null) - Row( - children: [ - Text( - subtitle, - style: theme.textTheme.labelSmall?.copyWith( - color: subtitle == 'Online' - ? Colors.green - : colorScheme.onSurfaceVariant, - ), + // Show dropdown arrow for group channels + if (isGroupChannel) ...[ + const SizedBox(width: 4), + Icon( + Icons.keyboard_arrow_down, + size: 16, + color: colorScheme.onSurfaceVariant, ), - // Show dropdown arrow for group channels - if (isGroupChannel) ...[ - const SizedBox(width: 4), - Icon( - Icons.keyboard_arrow_down, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - ], ], - ), - ], - ), + ], + ), + ], ), - ], - ), + ), + ], ), ), - body: Column( - children: [ - // Archived banner - if (isArchived) - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - color: colorScheme.surfaceContainerHighest, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.archive_outlined, - size: 16, + ), + body: Column( + children: [ + // Archived banner + if (isArchived) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + color: colorScheme.surfaceContainerHighest, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.archive_outlined, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'This chat is archived', + style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), - const SizedBox(width: 8), - Text( - 'This chat is archived', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), + ), + ], ), + ), - // Message list - Expanded( - child: StreamMessageListView( - messageBuilder: (context, details, messages, defaultWidget) { - // Show avatars in group chats, hide in DMs - return defaultWidget.copyWith( - showUserAvatar: isGroupChannel - ? DisplayWidget.show - : DisplayWidget.gone, - ); - }, - ), + // Message list + Expanded( + child: StreamMessageListView( + messageBuilder: (context, details, messages, defaultWidget) { + // Show avatars in group chats, hide in DMs + return defaultWidget.copyWith( + showUserAvatar: isGroupChannel + ? DisplayWidget.show + : DisplayWidget.gone, + ); + }, ), + ), - // Message input (hidden if archived) - if (!isArchived) - Container( - decoration: BoxDecoration( - color: colorScheme.surface, - border: Border( - top: BorderSide( - color: colorScheme.outlineVariant, - ), + // Message input (hidden if archived) + if (!isArchived) + Container( + decoration: BoxDecoration( + color: colorScheme.surface, + border: Border( + top: BorderSide( + color: colorScheme.outlineVariant, ), ), - child: SafeArea( - child: StreamMessageInput( - disableAttachments: false, - sendButtonLocation: SendButtonLocation.inside, - ), + ), + child: SafeArea( + child: StreamMessageInput( + disableAttachments: false, + sendButtonLocation: SendButtonLocation.inside, ), ), - ], - ), + ), + ], ), + ), ); } diff --git a/lib/features/chat/screens/messages_screen.dart b/lib/features/chat/screens/messages_screen.dart index 69644e0..9a08de4 100644 --- a/lib/features/chat/screens/messages_screen.dart +++ b/lib/features/chat/screens/messages_screen.dart @@ -14,9 +14,8 @@ class MessagesPlaceholderScreen extends ConsumerWidget { final colorScheme = theme.colorScheme; final user = ref.watch(currentUserProvider); final isConsultant = user?.role == UserRole.consultant; - final subtitle = isConsultant - ? 'Chat with your clients' - : 'Chat with your consultants'; + final subtitle = + isConsultant ? 'Chat with your clients' : 'Chat with your consultants'; return Scaffold( backgroundColor: colorScheme.surfaceContainerLowest, diff --git a/lib/features/chat/widgets/channel_members_sheet.dart b/lib/features/chat/widgets/channel_members_sheet.dart index 89cddff..9b4f44f 100644 --- a/lib/features/chat/widgets/channel_members_sheet.dart +++ b/lib/features/chat/widgets/channel_members_sheet.dart @@ -271,8 +271,18 @@ class _MemberTile extends StatelessWidget { } else { // Format as "Jan 15" const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' ]; return '${months[date.month - 1]} ${date.day}'; } diff --git a/lib/features/chat/widgets/chat_actions_sheet.dart b/lib/features/chat/widgets/chat_actions_sheet.dart index ba90af7..c97ebcb 100644 --- a/lib/features/chat/widgets/chat_actions_sheet.dart +++ b/lib/features/chat/widgets/chat_actions_sheet.dart @@ -61,7 +61,8 @@ Future showDestructiveActionDialog({ TextButton( onPressed: () => Navigator.of(context).pop(true), style: TextButton.styleFrom( - foregroundColor: confirmColor ?? Theme.of(context).colorScheme.error, + foregroundColor: + confirmColor ?? Theme.of(context).colorScheme.error, ), child: Text(confirmText), ), @@ -116,7 +117,9 @@ class _ChatActionsSheet extends StatelessWidget { // Option 1: Mute/Unmute notifications _ActionCard( - icon: isMuted ? Icons.notifications_active : Icons.notifications_off, + icon: isMuted + ? Icons.notifications_active + : Icons.notifications_off, iconColor: theme.colorScheme.primary, title: isMuted ? 'Unmute notifications' : 'Mute notifications', description: isMuted diff --git a/lib/features/checkout/providers/razorpay_service_provider.dart b/lib/features/checkout/providers/razorpay_service_provider.dart index 0aad26d..20383f1 100644 --- a/lib/features/checkout/providers/razorpay_service_provider.dart +++ b/lib/features/checkout/providers/razorpay_service_provider.dart @@ -23,7 +23,8 @@ String _getRazorpayUserMessage(int code, String rawMessage) { default: // Parse common error patterns from message final lowerMessage = rawMessage.toLowerCase(); - if (lowerMessage.contains('network') || lowerMessage.contains('connection')) { + if (lowerMessage.contains('network') || + lowerMessage.contains('connection')) { return 'Network error. Please check your connection and try again.'; } if (lowerMessage.contains('declined')) { @@ -177,7 +178,8 @@ class RazorpayService extends _$RazorpayService { ); return const RazorpayFailure( code: -1, - message: 'Unable to open payment. Please try again or use a different payment method.', + message: + 'Unable to open payment. Please try again or use a different payment method.', ); } } diff --git a/lib/features/checkout/widgets/payment_method_selector.dart b/lib/features/checkout/widgets/payment_method_selector.dart index 9a4c7e7..74651d9 100644 --- a/lib/features/checkout/widgets/payment_method_selector.dart +++ b/lib/features/checkout/widgets/payment_method_selector.dart @@ -22,27 +22,35 @@ class PaymentMethodSelector extends StatelessWidget { // Show Razorpay only for INR final showRazorpay = currency.toUpperCase() == 'INR'; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showRazorpay) + // RadioGroup owns the selection (Radio.groupValue/onChanged were + // deprecated in Flutter 3.35 — see breaking-changes/radio-api-redesign). + return RadioGroup( + groupValue: selectedGateway, + onChanged: (value) { + if (value != null) onGatewaySelected(value); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showRazorpay) + _buildGatewayOption( + context: context, + theme: theme, + gateway: PaymentGatewayType.razorpay, + title: 'Razorpay', + subtitle: 'UPI, Cards, Netbanking, Wallets', + icon: Icons.account_balance, + ), _buildGatewayOption( context: context, theme: theme, - gateway: PaymentGatewayType.razorpay, - title: 'Razorpay', - subtitle: 'UPI, Cards, Netbanking, Wallets', - icon: Icons.account_balance, + gateway: PaymentGatewayType.stripe, + title: 'Stripe', + subtitle: 'Credit/Debit Cards', + icon: Icons.credit_card, ), - _buildGatewayOption( - context: context, - theme: theme, - gateway: PaymentGatewayType.stripe, - title: 'Stripe', - subtitle: 'Credit/Debit Cards', - icon: Icons.credit_card, - ), - ], + ], + ), ); } @@ -121,10 +129,6 @@ class PaymentMethodSelector extends StatelessWidget { ), Radio( value: gateway, - groupValue: selectedGateway, - onChanged: (value) { - if (value != null) onGatewaySelected(value); - }, activeColor: theme.colorScheme.primary, ), ], diff --git a/lib/features/collaborations/screens/collaborations_screen.dart b/lib/features/collaborations/screens/collaborations_screen.dart index 2ac9f9f..417a427 100644 --- a/lib/features/collaborations/screens/collaborations_screen.dart +++ b/lib/features/collaborations/screens/collaborations_screen.dart @@ -57,7 +57,8 @@ class CollaborationsScreen extends ConsumerWidget { Icon( Icons.group_outlined, size: 64, - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: + theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( @@ -94,8 +95,7 @@ class CollaborationsScreen extends ConsumerWidget { ), const SizedBox(width: 8), Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: theme.colorScheme.error, borderRadius: BorderRadius.circular(10), diff --git a/lib/features/collaborations/widgets/collaboration_card.dart b/lib/features/collaborations/widgets/collaboration_card.dart index 287a3d8..ad41e24 100644 --- a/lib/features/collaborations/widgets/collaboration_card.dart +++ b/lib/features/collaborations/widgets/collaboration_card.dart @@ -159,8 +159,7 @@ class CollaborationCard extends StatelessWidget { if (!isPending) ...[ const SizedBox(height: 8), Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: Colors.green.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), @@ -193,8 +192,7 @@ class _TypeBadge extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: (isWebinar ? Colors.purple : Colors.blue) - .withValues(alpha: 0.1), + color: (isWebinar ? Colors.purple : Colors.blue).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text( diff --git a/lib/features/dashboard/providers/consultant_dashboard_provider.dart b/lib/features/dashboard/providers/consultant_dashboard_provider.dart index 1d0b5f8..a7d34e9 100644 --- a/lib/features/dashboard/providers/consultant_dashboard_provider.dart +++ b/lib/features/dashboard/providers/consultant_dashboard_provider.dart @@ -61,20 +61,17 @@ Future consultantDashboard(Ref ref) async { ? referralRepo.getAvailableCredits() : Future.value(const ReferralCreditsAvailable()); - final stats = await statsFuture - .catchError((_) => const ConsultantDashboardStats()); - final sessions = - await sessionsFuture.catchError((_) => []); - final requests = - await requestsFuture.catchError((_) => []); - final reviews = - await reviewsFuture.catchError((_) => []); - final earnings = await earningsFuture - .catchError((_) => const EarningsSummary()); - final collabData = await collabFuture - .catchError((_) => const CollaborationsResponse()); - final ReferralCodeInfo? referralCode = await referralCodeFuture - .catchError((_) => null); + final stats = + await statsFuture.catchError((_) => const ConsultantDashboardStats()); + final sessions = await sessionsFuture.catchError((_) => []); + final requests = await requestsFuture.catchError((_) => []); + final reviews = await reviewsFuture.catchError((_) => []); + final earnings = + await earningsFuture.catchError((_) => const EarningsSummary()); + final collabData = + await collabFuture.catchError((_) => const CollaborationsResponse()); + final ReferralCodeInfo? referralCode = + await referralCodeFuture.catchError((_) => null); final referralCredits = await referralCreditsFuture .catchError((_) => const ReferralCreditsAvailable()); diff --git a/lib/features/dashboard/screens/consultee_dashboard_screen.dart b/lib/features/dashboard/screens/consultee_dashboard_screen.dart index 49c5226..ef6aad8 100644 --- a/lib/features/dashboard/screens/consultee_dashboard_screen.dart +++ b/lib/features/dashboard/screens/consultee_dashboard_screen.dart @@ -133,9 +133,8 @@ class ConsulteeDashboardScreen extends ConsumerWidget { // Upcoming sessions DashboardSectionHeader( title: 'Upcoming Sessions', - onViewAll: sessions.isNotEmpty - ? () => context.push('/my-bookings') - : null, + onViewAll: + sessions.isNotEmpty ? () => context.push('/my-bookings') : null, ), if (sessions.isEmpty) _buildEmptyState(context) diff --git a/lib/features/dashboard/widgets/referral_summary_card.dart b/lib/features/dashboard/widgets/referral_summary_card.dart index 07e5bb7..89c3fba 100644 --- a/lib/features/dashboard/widgets/referral_summary_card.dart +++ b/lib/features/dashboard/widgets/referral_summary_card.dart @@ -50,14 +50,13 @@ class ReferralSummaryCard extends StatelessWidget { ], ), const SizedBox(height: 12), - if (code != null) ...[ // Show code with copy + share Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer - .withValues(alpha: 0.3), + color: + theme.colorScheme.primaryContainer.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), ), child: Row( diff --git a/lib/features/explore/screens/consultant_profile_screen.dart b/lib/features/explore/screens/consultant_profile_screen.dart index 0aa27c1..a51f0a6 100644 --- a/lib/features/explore/screens/consultant_profile_screen.dart +++ b/lib/features/explore/screens/consultant_profile_screen.dart @@ -314,8 +314,7 @@ class ConsultantProfileScreen extends ConsumerWidget { AppSentryLogger.captureException( e, stackTrace: stackTrace, - context: - 'ConsultantProfileScreen.submitReview', + context: 'ConsultantProfileScreen.submitReview', extras: {'consultantId': consultantId}, ); } diff --git a/lib/features/maintenance/providers/maintenance_provider.dart b/lib/features/maintenance/providers/maintenance_provider.dart index ef3aa5a..bc74c5c 100644 --- a/lib/features/maintenance/providers/maintenance_provider.dart +++ b/lib/features/maintenance/providers/maintenance_provider.dart @@ -1,4 +1,3 @@ -import 'package:dio/dio.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../shared/providers/core_providers.dart'; diff --git a/lib/features/maintenance/screens/maintenance_screen.dart b/lib/features/maintenance/screens/maintenance_screen.dart index 42ec736..acd5637 100644 --- a/lib/features/maintenance/screens/maintenance_screen.dart +++ b/lib/features/maintenance/screens/maintenance_screen.dart @@ -10,9 +10,8 @@ class MaintenanceScreen extends StatelessWidget { appBar: AppBar( title: const Text('Maintenance'), leading: BackButton( - onPressed: () => context.canPop() - ? context.pop() - : context.go('/dashboard'), + onPressed: () => + context.canPop() ? context.pop() : context.go('/dashboard'), ), ), body: Center( diff --git a/lib/features/onboarding/screens/steps/preferences_step.dart b/lib/features/onboarding/screens/steps/preferences_step.dart index f7bbe1a..8398c4a 100644 --- a/lib/features/onboarding/screens/steps/preferences_step.dart +++ b/lib/features/onboarding/screens/steps/preferences_step.dart @@ -72,66 +72,71 @@ class _PreferencesStepState extends ConsumerState { // Budget Preference const SubSectionHeader(title: 'Budget Preference'), const SizedBox(height: 8), - ...BudgetPreference.values.map((budget) { - final isSelected = _selectedBudget == budget; - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: InkWell( - onTap: () { - setState(() => _selectedBudget = budget); - _updatePreferences(); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: isSelected ? 2 : 1, - ), + // RadioGroup owns the selection (Radio.groupValue/onChanged were + // deprecated in Flutter 3.35 — radio-api-redesign). + RadioGroup( + groupValue: _selectedBudget, + onChanged: (value) { + setState(() => _selectedBudget = value); + _updatePreferences(); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: BudgetPreference.values.map((budget) { + final isSelected = _selectedBudget == budget; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: () { + setState(() => _selectedBudget = budget); + _updatePreferences(); + }, borderRadius: BorderRadius.circular(12), - color: isSelected - ? colorScheme.primaryContainer.withAlpha(30) - : null, - ), - child: Row( - children: [ - Radio( - value: budget, - groupValue: _selectedBudget, - onChanged: (value) { - setState(() => _selectedBudget = value); - _updatePreferences(); - }, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12), + color: isSelected + ? colorScheme.primaryContainer.withAlpha(30) + : null, ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _budgetLabel(budget), - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + child: Row( + children: [ + Radio(value: budget), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _budgetLabel(budget), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _budgetDescription(budget), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], ), - Text( - _budgetDescription(budget), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), + ), + ], ), - ], + ), ), - ), - ), - ); - }), + ); + }).toList(), + ), + ), const SizedBox(height: 24), // Communication Preference const SubSectionHeader(title: 'Preferred Communication Method'), diff --git a/lib/features/onboarding/screens/steps/professional_background_step.dart b/lib/features/onboarding/screens/steps/professional_background_step.dart index f06b96b..f7544bd 100644 --- a/lib/features/onboarding/screens/steps/professional_background_step.dart +++ b/lib/features/onboarding/screens/steps/professional_background_step.dart @@ -414,14 +414,12 @@ class _WorkExperienceDialogState extends State<_WorkExperienceDialog> { WorkExperienceEntry( company: _companyCtrl.text, title: _titleCtrl.text, - location: _locationCtrl.text.isNotEmpty - ? _locationCtrl.text - : null, + location: + _locationCtrl.text.isNotEmpty ? _locationCtrl.text : null, startDate: _startDate, endDate: _endDate, isCurrent: _isCurrent, - description: - _descCtrl.text.isNotEmpty ? _descCtrl.text : null, + description: _descCtrl.text.isNotEmpty ? _descCtrl.text : null, ), ); }, @@ -466,8 +464,7 @@ class _EducationDialogState extends State<_EducationDialog> { children: [ TextField( controller: _institutionCtrl, - decoration: - const InputDecoration(labelText: 'Institution *'), + decoration: const InputDecoration(labelText: 'Institution *'), ), TextField( controller: _degreeCtrl, @@ -475,8 +472,7 @@ class _EducationDialogState extends State<_EducationDialog> { ), TextField( controller: _fieldCtrl, - decoration: - const InputDecoration(labelText: 'Field of Study'), + decoration: const InputDecoration(labelText: 'Field of Study'), ), TextField( controller: _startYearCtrl, @@ -498,8 +494,7 @@ class _EducationDialogState extends State<_EducationDialog> { ), FilledButton( onPressed: () { - if (_institutionCtrl.text.isEmpty || - _degreeCtrl.text.isEmpty) { + if (_institutionCtrl.text.isEmpty || _degreeCtrl.text.isEmpty) { return; } Navigator.pop( @@ -507,9 +502,8 @@ class _EducationDialogState extends State<_EducationDialog> { EducationEntry( institution: _institutionCtrl.text, degree: _degreeCtrl.text, - fieldOfStudy: _fieldCtrl.text.isNotEmpty - ? _fieldCtrl.text - : null, + fieldOfStudy: + _fieldCtrl.text.isNotEmpty ? _fieldCtrl.text : null, startYear: int.tryParse(_startYearCtrl.text), endYear: int.tryParse(_endYearCtrl.text), ), @@ -582,13 +576,11 @@ class _CertificationDialogState extends State<_CertificationDialog> { ), TextField( controller: _credIdCtrl, - decoration: - const InputDecoration(labelText: 'Credential ID'), + decoration: const InputDecoration(labelText: 'Credential ID'), ), TextField( controller: _credUrlCtrl, - decoration: - const InputDecoration(labelText: 'Credential URL'), + decoration: const InputDecoration(labelText: 'Credential URL'), ), ], ), @@ -607,12 +599,10 @@ class _CertificationDialogState extends State<_CertificationDialog> { name: _nameCtrl.text, issuingOrganization: _orgCtrl.text, issueDate: _issueDate, - credentialId: _credIdCtrl.text.isNotEmpty - ? _credIdCtrl.text - : null, - credentialUrl: _credUrlCtrl.text.isNotEmpty - ? _credUrlCtrl.text - : null, + credentialId: + _credIdCtrl.text.isNotEmpty ? _credIdCtrl.text : null, + credentialUrl: + _credUrlCtrl.text.isNotEmpty ? _credUrlCtrl.text : null, ), ); }, diff --git a/lib/features/onboarding/widgets/form_dropdown.dart b/lib/features/onboarding/widgets/form_dropdown.dart index b712272..0ab32f7 100644 --- a/lib/features/onboarding/widgets/form_dropdown.dart +++ b/lib/features/onboarding/widgets/form_dropdown.dart @@ -55,7 +55,7 @@ class FormDropdown extends StatelessWidget { const SizedBox(height: 6), ], DropdownButtonFormField( - value: value, + initialValue: value, items: items, onChanged: enabled ? onChanged : null, hint: hint != null ? Text(hint!) : null, diff --git a/lib/features/onboarding/widgets/multi_select_chips.dart b/lib/features/onboarding/widgets/multi_select_chips.dart index 7fbe8fc..27b060e 100644 --- a/lib/features/onboarding/widgets/multi_select_chips.dart +++ b/lib/features/onboarding/widgets/multi_select_chips.dart @@ -129,8 +129,9 @@ class _TagInputState extends State { final trimmed = tag.trim(); if (trimmed.isEmpty) return; // Case-insensitive duplicate check to prevent "React" and "react" as separate tags - if (widget.tags.any((t) => t.toLowerCase() == trimmed.toLowerCase())) + if (widget.tags.any((t) => t.toLowerCase() == trimmed.toLowerCase())) { return; + } if (widget.maxTags != null && widget.tags.length >= widget.maxTags!) return; widget.onChanged([...widget.tags, trimmed]); diff --git a/lib/features/onboarding/widgets/role_selection_card.dart b/lib/features/onboarding/widgets/role_selection_card.dart index 8a2998b..06b4016 100644 --- a/lib/features/onboarding/widgets/role_selection_card.dart +++ b/lib/features/onboarding/widgets/role_selection_card.dart @@ -86,11 +86,10 @@ class RoleSelectionCard extends StatelessWidget { ], ), ), - Radio( - value: role, - groupValue: isSelected ? role : null, - onChanged: (_) => onTap(), - ), + // Selection state comes from the RadioGroup ancestor in + // RoleSelector; the old `groupValue: isSelected ? role : null` hack + // is no longer needed (radio-api-redesign, Flutter 3.35). + Radio(value: role), ], ), ), @@ -111,20 +110,26 @@ class RoleSelector extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - RoleSelectionCard( - role: UserRole.consultee, - isSelected: selectedRole == UserRole.consultee, - onTap: () => onRoleSelected(UserRole.consultee), - ), - const SizedBox(height: 16), - RoleSelectionCard( - role: UserRole.consultant, - isSelected: selectedRole == UserRole.consultant, - onTap: () => onRoleSelected(UserRole.consultant), - ), - ], + return RadioGroup( + groupValue: selectedRole, + onChanged: (value) { + if (value != null) onRoleSelected(value); + }, + child: Column( + children: [ + RoleSelectionCard( + role: UserRole.consultee, + isSelected: selectedRole == UserRole.consultee, + onTap: () => onRoleSelected(UserRole.consultee), + ), + const SizedBox(height: 16), + RoleSelectionCard( + role: UserRole.consultant, + isSelected: selectedRole == UserRole.consultant, + onTap: () => onRoleSelected(UserRole.consultant), + ), + ], + ), ); } } diff --git a/lib/features/organization/screens/my_organization_screen.dart b/lib/features/organization/screens/my_organization_screen.dart index 8618b17..ef85288 100644 --- a/lib/features/organization/screens/my_organization_screen.dart +++ b/lib/features/organization/screens/my_organization_screen.dart @@ -220,7 +220,8 @@ class _EntitlementMeter extends StatelessWidget { theme, label: '${entitlement.engagementsRemaining ?? 0} of $covered ' 'sessions left this cycle', - fraction: covered == 0 ? 0 : (covered - used).clamp(0, covered) / covered, + fraction: + covered == 0 ? 0 : (covered - used).clamp(0, covered) / covered, ); } diff --git a/lib/features/payout/providers/payout_provider.dart b/lib/features/payout/providers/payout_provider.dart index 9a92e6f..5c13fdc 100644 --- a/lib/features/payout/providers/payout_provider.dart +++ b/lib/features/payout/providers/payout_provider.dart @@ -58,9 +58,7 @@ class PayoutAccounts extends _$PayoutAccounts { await source.setDefault(id); final current = state.valueOrNull ?? []; state = AsyncData( - current - .map((a) => a.copyWith(isDefault: a.id == id)) - .toList(), + current.map((a) => a.copyWith(isDefault: a.id == id)).toList(), ); } catch (e, stack) { AppSentryLogger.captureException(e, diff --git a/lib/features/payout/screens/add_payout_account_screen.dart b/lib/features/payout/screens/add_payout_account_screen.dart index 26d64ad..606b368 100644 --- a/lib/features/payout/screens/add_payout_account_screen.dart +++ b/lib/features/payout/screens/add_payout_account_screen.dart @@ -56,8 +56,7 @@ class _AddPayoutAccountScreenState ), ], selected: {_accountType}, - onSelectionChanged: (v) => - setState(() => _accountType = v.first), + onSelectionChanged: (v) => setState(() => _accountType = v.first), ), const SizedBox(height: 24), TextField( @@ -133,18 +132,13 @@ class _AddPayoutAccountScreenState await ref.read(payoutAccountsProvider.notifier).create({ 'provider': 'RAZORPAY', 'accountType': _accountType, - 'accountHolderName': _holderNameCtrl.text.isEmpty - ? null - : _holderNameCtrl.text, + 'accountHolderName': + _holderNameCtrl.text.isEmpty ? null : _holderNameCtrl.text, if (_accountType == 'BANK_ACCOUNT') ...{ - 'bankName': _bankNameCtrl.text.isEmpty - ? null - : _bankNameCtrl.text, - 'accountNumberLast4': _last4Ctrl.text.isEmpty - ? null - : _last4Ctrl.text, - 'ifscCode': - _ifscCtrl.text.isEmpty ? null : _ifscCtrl.text, + 'bankName': _bankNameCtrl.text.isEmpty ? null : _bankNameCtrl.text, + 'accountNumberLast4': + _last4Ctrl.text.isEmpty ? null : _last4Ctrl.text, + 'ifscCode': _ifscCtrl.text.isEmpty ? null : _ifscCtrl.text, } else ...{ 'upiId': _upiCtrl.text.isEmpty ? null : _upiCtrl.text, }, diff --git a/lib/features/payout/screens/payout_accounts_screen.dart b/lib/features/payout/screens/payout_accounts_screen.dart index 00e5bce..0e21c23 100644 --- a/lib/features/payout/screens/payout_accounts_screen.dart +++ b/lib/features/payout/screens/payout_accounts_screen.dart @@ -43,10 +43,14 @@ class PayoutAccountsScreen extends ConsumerWidget { itemBuilder: (context, index) => _AccountCard( account: accounts[index], onSetDefault: () => _setDefault( - ref, context, accounts[index].id, + ref, + context, + accounts[index].id, ), onDelete: () => _delete( - ref, context, accounts[index].id, + ref, + context, + accounts[index].id, ), ), ), @@ -72,7 +76,9 @@ class PayoutAccountsScreen extends ConsumerWidget { } Future _setDefault( - WidgetRef ref, BuildContext context, String id, + WidgetRef ref, + BuildContext context, + String id, ) async { try { await ref.read(payoutAccountsProvider.notifier).setDefault(id); @@ -93,7 +99,9 @@ class PayoutAccountsScreen extends ConsumerWidget { } Future _delete( - WidgetRef ref, BuildContext context, String id, + WidgetRef ref, + BuildContext context, + String id, ) async { final confirmed = await showDialog( context: context, @@ -171,10 +179,9 @@ class _AccountCard extends StatelessWidget { const Spacer(), if (account.isDefault) Chip( - label: const Text('Default', - style: TextStyle(fontSize: 11)), - backgroundColor: - Colors.green.withValues(alpha: 0.1), + label: + const Text('Default', style: TextStyle(fontSize: 11)), + backgroundColor: Colors.green.withValues(alpha: 0.1), side: BorderSide.none, visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, @@ -186,8 +193,7 @@ class _AccountCard extends StatelessWidget { Text(account.accountHolderName!), if (account.accountNumberLast4 != null) Text('****${account.accountNumberLast4}'), - if (account.upiId != null) - Text(account.upiId!), + if (account.upiId != null) Text(account.upiId!), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, diff --git a/lib/features/profile/screens/profile_screen.dart b/lib/features/profile/screens/profile_screen.dart index 7a74d3d..47c305f 100644 --- a/lib/features/profile/screens/profile_screen.dart +++ b/lib/features/profile/screens/profile_screen.dart @@ -186,16 +186,13 @@ class ProfileScreen extends ConsumerWidget { ), actions: [ TextButton( - onPressed: () => - Navigator.pop(context, false), + onPressed: () => Navigator.pop(context, false), child: const Text('Cancel'), ), TextButton( - onPressed: () => - Navigator.pop(context, true), + onPressed: () => Navigator.pop(context, true), style: TextButton.styleFrom( - foregroundColor: - Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.error, ), child: const Text('Delete'), ), @@ -211,7 +208,8 @@ class ProfileScreen extends ConsumerWidget { final errorState = ref.read(deleteAccountProvider); final errorMessage = errorState.maybeWhen( error: (error, _) => error.toString(), - orElse: () => 'Failed to delete account. Please try again.', + orElse: () => + 'Failed to delete account. Please try again.', ); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(errorMessage)), diff --git a/lib/features/programs/screens/class_detail_screen.dart b/lib/features/programs/screens/class_detail_screen.dart index 2f479d3..5f47499 100644 --- a/lib/features/programs/screens/class_detail_screen.dart +++ b/lib/features/programs/screens/class_detail_screen.dart @@ -154,8 +154,7 @@ class _ClassDetailContent extends StatelessWidget { _buildSessionsSection(), // Spots remaining indicator - if (classPlan.spotsRemaining < - classPlan.maxParticipants) ...[ + if (classPlan.spotsRemaining < classPlan.maxParticipants) ...[ const SizedBox(height: 24), ClassSpotsRemainingBanner( spotsRemaining: classPlan.spotsRemaining, diff --git a/lib/features/staff/providers/staff_provider.dart b/lib/features/staff/providers/staff_provider.dart index 94589e7..2997be7 100644 --- a/lib/features/staff/providers/staff_provider.dart +++ b/lib/features/staff/providers/staff_provider.dart @@ -17,8 +17,7 @@ Future> staffStats(Ref ref) async { Future>> staffTickets(Ref ref) async { final dio = ref.watch(dioProvider); final response = await dio.get('/api/staff/support-tickets'); - return (response.data['data'] as List) - .cast>(); + return (response.data['data'] as List).cast>(); } @riverpod @@ -26,8 +25,6 @@ Future>> pendingVerifications( Ref ref, ) async { final dio = ref.watch(dioProvider); - final response = - await dio.get('/api/staff/moderation/profiles'); - return (response.data['data'] as List) - .cast>(); + final response = await dio.get('/api/staff/moderation/profiles'); + return (response.data['data'] as List).cast>(); } diff --git a/lib/features/tax/providers/tax_provider.dart b/lib/features/tax/providers/tax_provider.dart index 17880b7..20baffa 100644 --- a/lib/features/tax/providers/tax_provider.dart +++ b/lib/features/tax/providers/tax_provider.dart @@ -1,4 +1,3 @@ -import 'package:dio/dio.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/utils/sentry_logger.dart'; diff --git a/lib/features/tax/screens/tax_info_screen.dart b/lib/features/tax/screens/tax_info_screen.dart index 8912295..b09a85a 100644 --- a/lib/features/tax/screens/tax_info_screen.dart +++ b/lib/features/tax/screens/tax_info_screen.dart @@ -94,8 +94,7 @@ class _TaxInfoScreenState extends ConsumerState { ), ); }, - loading: () => - const Center(child: CircularProgressIndicator()), + loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error: $e')), ), ); @@ -105,10 +104,8 @@ class _TaxInfoScreenState extends ConsumerState { setState(() => _isSubmitting = true); try { await ref.read(taxInfoStateProvider.notifier).save({ - 'panNumber': - _panCtrl.text.isEmpty ? null : _panCtrl.text, - 'gstNumber': - _gstCtrl.text.isEmpty ? null : _gstCtrl.text, + 'panNumber': _panCtrl.text.isEmpty ? null : _panCtrl.text, + 'gstNumber': _gstCtrl.text.isEmpty ? null : _gstCtrl.text, }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/features/trials/screens/trial_list_screen.dart b/lib/features/trials/screens/trial_list_screen.dart index ef4101e..4c3174f 100644 --- a/lib/features/trials/screens/trial_list_screen.dart +++ b/lib/features/trials/screens/trial_list_screen.dart @@ -101,8 +101,7 @@ class _TrialListScreenState extends ConsumerState { color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(12), border: Border.all( - color: - theme.colorScheme.outlineVariant.withValues(alpha: 0.5), + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5), ), ), child: IconButton( @@ -206,8 +205,7 @@ class _TrialListScreenState extends ConsumerState { color: isSelected ? theme.colorScheme.onPrimary : theme.colorScheme.onSurfaceVariant, - fontWeight: - isSelected ? FontWeight.w600 : FontWeight.normal, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, fontSize: 12, ), ), @@ -226,12 +224,10 @@ class _TrialListScreenState extends ConsumerState { side: BorderSide( color: isSelected ? theme.colorScheme.primary - : theme.colorScheme.outlineVariant - .withValues(alpha: 0.5), + : theme.colorScheme.outlineVariant.withValues(alpha: 0.5), ), ), - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), @@ -337,8 +333,8 @@ class _TrialListScreenState extends ConsumerState { width: 80, height: 80, decoration: BoxDecoration( - color: theme.colorScheme.errorContainer - .withValues(alpha: 0.3), + color: + theme.colorScheme.errorContainer.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(24), ), child: Icon( diff --git a/lib/features/trials/screens/trial_request_screen.dart b/lib/features/trials/screens/trial_request_screen.dart index b416b3f..63a4352 100644 --- a/lib/features/trials/screens/trial_request_screen.dart +++ b/lib/features/trials/screens/trial_request_screen.dart @@ -19,8 +19,7 @@ class TrialRequestScreen extends ConsumerStatefulWidget { final String subscriptionPlanId; @override - ConsumerState createState() => - _TrialRequestScreenState(); + ConsumerState createState() => _TrialRequestScreenState(); } class _TrialRequestScreenState extends ConsumerState { @@ -62,8 +61,9 @@ class _TrialRequestScreenState extends ConsumerState { ), const SizedBox(height: 8), Text( - eligibility.reason ?? 'You are not eligible ' - 'for a trial with this consultant.', + eligibility.reason ?? + 'You are not eligible ' + 'for a trial with this consultant.', textAlign: TextAlign.center, ), ], @@ -130,9 +130,7 @@ class _TrialRequestScreenState extends ConsumerState { await ref.read(trialListProvider.notifier).requestTrial( consultantProfileId: widget.consultantProfileId, subscriptionPlanId: widget.subscriptionPlanId, - notes: _notesController.text.isEmpty - ? null - : _notesController.text, + notes: _notesController.text.isEmpty ? null : _notesController.text, ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/features/trials/widgets/trial_card.dart b/lib/features/trials/widgets/trial_card.dart index 0d3b95a..d7a8c70 100644 --- a/lib/features/trials/widgets/trial_card.dart +++ b/lib/features/trials/widgets/trial_card.dart @@ -31,9 +31,8 @@ class TrialCard extends StatelessWidget { final displayName = isConsultantView ? (trial.consulteeName ?? 'Consultee') : (trial.consultantName ?? 'Consultant'); - final displayImage = isConsultantView - ? trial.consulteeImage - : trial.consultantImage; + final displayImage = + isConsultantView ? trial.consulteeImage : trial.consultantImage; return Material( color: colorScheme.surface, @@ -173,7 +172,8 @@ class TrialCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12), visualDensity: VisualDensity.compact, ), - child: const Text('Reject', style: TextStyle(fontSize: 12)), + child: const Text('Reject', + style: TextStyle(fontSize: 12)), ), ), const SizedBox(width: 8), @@ -186,7 +186,8 @@ class TrialCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12), visualDensity: VisualDensity.compact, ), - child: const Text('Accept', style: TextStyle(fontSize: 12)), + child: const Text('Accept', + style: TextStyle(fontSize: 12)), ), ), ], diff --git a/lib/features/verification/providers/verification_provider.dart b/lib/features/verification/providers/verification_provider.dart index d5f1843..fa05b9c 100644 --- a/lib/features/verification/providers/verification_provider.dart +++ b/lib/features/verification/providers/verification_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/utils/sentry_logger.dart'; diff --git a/lib/features/verification/screens/verification_status_screen.dart b/lib/features/verification/screens/verification_status_screen.dart index c783fae..3d2d5a9 100644 --- a/lib/features/verification/screens/verification_status_screen.dart +++ b/lib/features/verification/screens/verification_status_screen.dart @@ -89,8 +89,7 @@ class VerificationStatusScreen extends ConsumerWidget { final documentsAsync = ref.watch(verificationDocumentsProvider); return RefreshIndicator( - onRefresh: () => - ref.read(verificationStateProvider.notifier).refresh(), + onRefresh: () => ref.read(verificationStateProvider.notifier).refresh(), child: ListView( padding: const EdgeInsets.all(16), children: [ @@ -176,8 +175,7 @@ class VerificationStatusScreen extends ConsumerWidget { .toList(), ); }, - loading: () => - const Center(child: CircularProgressIndicator()), + loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Text('Failed to load documents: $e'), ), diff --git a/lib/features/verification/screens/verification_submit_screen.dart b/lib/features/verification/screens/verification_submit_screen.dart index 8e77aca..990c3b5 100644 --- a/lib/features/verification/screens/verification_submit_screen.dart +++ b/lib/features/verification/screens/verification_submit_screen.dart @@ -94,8 +94,7 @@ class _VerificationSubmitScreenState subtitle: Text(f.description ?? 'Document'), trailing: IconButton( icon: const Icon(Icons.close), - onPressed: () => - setState(() => _uploadedFiles.remove(f)), + onPressed: () => setState(() => _uploadedFiles.remove(f)), ), ), ), @@ -230,8 +229,7 @@ class _VerificationSubmitScreenState } // Add documents to the verification - final docsNotifier = - ref.read(verificationDocumentsProvider.notifier); + final docsNotifier = ref.read(verificationDocumentsProvider.notifier); for (final file in _uploadedFiles) { await docsNotifier.addDocument( fileName: file.fileName, diff --git a/lib/features/waitlist/providers/waitlist_provider.dart b/lib/features/waitlist/providers/waitlist_provider.dart index f516447..9641f6f 100644 --- a/lib/features/waitlist/providers/waitlist_provider.dart +++ b/lib/features/waitlist/providers/waitlist_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/utils/sentry_logger.dart'; diff --git a/lib/main.dart b/lib/main.dart index 9be0dfd..a2b57bf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,6 +27,9 @@ Future main() async { // Disable heavy features to keep it lightweight options.attachScreenshot = false; + // Experimental in the Sentry SDK, but we only ever set it to false to + // keep payloads light — no behaviour depends on the API shape. + // ignore: experimental_member_use options.attachViewHierarchy = false; // Breadcrumb limit to avoid memory overhead diff --git a/lib/shared/utils/fake_data.dart b/lib/shared/utils/fake_data.dart index 6d416af..b8a007a 100644 --- a/lib/shared/utils/fake_data.dart +++ b/lib/shared/utils/fake_data.dart @@ -129,8 +129,7 @@ class FakeData { List.generate(count, (_) => classPlan()); // --- AppointmentConsultant --- - static AppointmentConsultant appointmentConsultant() => - AppointmentConsultant( + static AppointmentConsultant appointmentConsultant() => AppointmentConsultant( consultantUserId: BoneMock.name, consultantName: BoneMock.name, lastAppointmentType: BookingType.consultation, diff --git a/pubspec.lock b/pubspec.lock index 734f7b1..b7bfcb8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -145,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" build: dependency: transitive description: @@ -241,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -699,6 +715,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_hooks: + dependency: transitive + description: + name: flutter_hooks + sha256: "8ae1f090e5f4ef5cfa6670ce1ab5dddadd33f3533a7f9ba19d9f958aa2a89f42" + url: "https://pub.dev" + source: hosted + version: "0.21.3+1" flutter_lints: dependency: "direct dev" description: @@ -1007,6 +1031,78 @@ packages: url: "https://pub.dev" source: hosted version: "2.18.0" + gql: + dependency: transitive + description: + name: gql + sha256: "67c32325eb55c15f526f0f5e7d8b38a463dbff2ec3c2e046be4a1a95f0dc93d1" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + gql_dedupe_link: + dependency: transitive + description: + name: gql_dedupe_link + sha256: "10bee0564d67c24e0c8bd08bd56e0682b64a135e58afabbeed30d85d5e9fea96" + url: "https://pub.dev" + source: hosted + version: "2.0.4-alpha+1715521079596" + gql_error_link: + dependency: transitive + description: + name: gql_error_link + sha256: dd0f3fbfbcec848ea050507470cdb5d3dc47d29544ae11044a1c883cbe159ccc + url: "https://pub.dev" + source: hosted + version: "1.0.1" + gql_exec: + dependency: transitive + description: + name: gql_exec + sha256: "394944626fae900f1d34343ecf2d62e44eb984826189c8979d305f0ae5846e38" + url: "https://pub.dev" + source: hosted + version: "1.1.1-alpha+1699813812660" + gql_http_link: + dependency: transitive + description: + name: gql_http_link + sha256: "07635e85a4f313836904961904417fd27844fe8f68f77b410a4e6b81d8e9202e" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + gql_link: + dependency: transitive + description: + name: gql_link + sha256: "0730276ce3a6a0ced073194ff923a8d99b3c78e442cbf096eb54fd0c3fa9f974" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + gql_transform_link: + dependency: transitive + description: + name: gql_transform_link + sha256: b3bb06a6991bc5c9d877e2757455f80e2c14dc684b8327bedae4f4ee67afae8b + url: "https://pub.dev" + source: hosted + version: "1.0.1" + graphql: + dependency: transitive + description: + name: graphql + sha256: a7cb0b5e8719546bf8d4edf5f57c3690ddf0fcce379c0d9d2287fdab73481090 + url: "https://pub.dev" + source: hosted + version: "5.2.4" + graphql_flutter: + dependency: transitive + description: + name: graphql_flutter + sha256: "4164962170998bc88bed833d1aa6efc5c3a85cbc8e66a8e62f790b43f4953980" + url: "https://pub.dev" + source: hosted + version: "5.3.0" graphs: dependency: transitive description: @@ -1031,6 +1127,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + hive_ce: + dependency: transitive + description: + name: hive_ce + sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418" + url: "https://pub.dev" + source: hosted + version: "2.19.3" html: dependency: transitive description: @@ -1180,6 +1284,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + isolate_channel: + dependency: transitive + description: + name: isolate_channel + sha256: a9d3d620695bc984244dafae00b95e4319d6974b2d77f4b9e1eb4f2efe099094 + url: "https://pub.dev" + source: hosted + version: "0.6.1" jiffy: dependency: transitive description: @@ -1380,6 +1492,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + mysql1: + dependency: transitive + description: + name: mysql1 + sha256: "68aec7003d2abc85769bafa1777af3f4a390a90c31032b89636758ff8eb839e9" + url: "https://pub.dev" + source: hosted + version: "0.20.0" nested: dependency: transitive description: @@ -1396,6 +1516,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + normalize: + dependency: transitive + description: + name: normalize + sha256: "703f0af9e6f43a5a71536e977b945238bc89f1a941347e7ba467865a20cc1a9f" + url: "https://pub.dev" + source: hosted + version: "0.10.0" octo_image: dependency: transitive description: @@ -1604,6 +1732,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + postgres: + dependency: transitive + description: + name: postgres + sha256: "123de5cbadc56a7e8d9fa485c780b6b56940b4081f4c74f3a5578682757c299b" + url: "https://pub.dev" + source: hosted + version: "3.5.12" postgrest: dependency: transitive description: @@ -1612,6 +1748,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.0" + prisma_flutter_connector: + dependency: "direct main" + description: + name: prisma_flutter_connector + sha256: "0253cb30f9728a17130010700dc72982136ccdc03e9b1cbb11c58876c5becbdb" + url: "https://pub.dev" + source: hosted + version: "0.9.0" process: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b08b56e..d7e32ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -82,7 +82,7 @@ dependencies: flutter_web_auth_2: ^4.1.0 http: ^1.6.0 file_picker: ^10.3.10 - prisma_flutter_connector: ^0.5.5 + prisma_flutter_connector: ^0.9.0 dev_dependencies: sentry_dart_plugin: ^3.2.0