From bc5e259189a6a4daf5294612faa9ecb2ee57a1f5 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 11 May 2026 22:53:32 +0200 Subject: [PATCH] feat(codegen): ship static schema.gql + auto-regen client codegen on install Apps/api now writes its GraphQL schema to apps/api/schema.gql on every dev boot (autoSchemaFile points at a path in non-production). Apps/client codegen defaults to reading that file instead of HTTP-introspecting a live API, and apps/client gets a prepare script that regenerates src/generated/graphql.ts if it is missing. Result: a fresh clone can typecheck the client immediately after pnpm install, without any database, API server, or codegen ceremony. Schema drifts surface as diffs in apps/api/schema.gql during PR review. Production keeps autoSchemaFile: true (in-memory only), so the file is purely a dev-time artifact. --- apps/api/schema.gql | 781 +++++++++++++++++++++++++++++ apps/api/src/modules/app.module.ts | 6 +- apps/client/codegen.ts | 2 +- apps/client/package.json | 3 +- 4 files changed, 789 insertions(+), 3 deletions(-) create mode 100644 apps/api/schema.gql diff --git a/apps/api/schema.gql b/apps/api/schema.gql new file mode 100644 index 0000000..434786e --- /dev/null +++ b/apps/api/schema.gql @@ -0,0 +1,781 @@ +# ------------------------------------------------------ +# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) +# ------------------------------------------------------ + +type ActiveSession { + city: String + country: String + createdAt: DateTime! + deviceName: String + deviceType: String + expiresAt: DateTime! + id: String! + isCurrentSession: Boolean! +} + +enum AdminEntityType { + APPLICATION_STATUS + COMPANY + INSTITUTION + JOB_TYPE + LEVEL + ORGANIZATION + ORGANIZATION_ROLE + ROLE + SKILL +} + +type AdminLookupEntity { + createdAt: DateTime! + description: String + id: ID! + name: String! + updatedAt: DateTime! +} + +type AiCallLogEntryType { + completionTokens: Int + durationMs: Int! + error: String + finishReason: String + id: String! + model: String + promptTokens: Int + providerName: String! + source: String + status: String! + timestamp: String! + userId: String +} + +"""User AI preference mode""" +enum AiPreference { + BYOK + NO_AI + PLATFORM +} + +type AiProviderStatus { + """JSON-serialized provider details""" + detailsJson: String! + healthy: Boolean! + providerName: String! +} + +type AiSettings { + activeProviderId: String + aiPreference: AiPreference! + id: String! +} + +type Application { + appliedAt: Date! + coverLetter: String + createdAt: Date! + cv: CV + cvId: ID + id: ID! + profileId: ID! + status: ApplicationStatus! + statusId: ID! + updatedAt: Date! + userId: ID! + vacancy: Vacancy! + vacancyId: ID! +} + +type ApplicationConnection { + edges: [ApplicationEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type ApplicationEdge { + cursor: String! + node: Application! +} + +type ApplicationStatus { + createdAt: Date! + description: String + id: ID! + name: String! + updatedAt: Date! +} + +type AuthenticationResponse { + accessTokenExpiration: TokenExpiration! + user: User! +} + +type CV { + createdAt: DateTime! + id: ID! + introduction: String + profileId: String! + template: CVTemplate! + title: String! + updatedAt: DateTime! +} + +type CVConnection { + edges: [CVEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type CVEdge { + cursor: String! + node: CV! +} + +type CVTemplate { + body: String + createdAt: DateTime! + css: String + description: String + engine: String! + id: ID! + name: String! + updatedAt: DateTime! +} + +type CVTemplateConnection { + edges: [CVTemplateEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type CVTemplateEdge { + cursor: String! + node: CVTemplate! +} + +type Company { + createdAt: DateTime! + description: String + id: ID! + name: String! + updatedAt: DateTime! + website: String +} + +type CompanyConnection { + edges: [CompanyEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type CompanyEdge { + cursor: String! + node: Company! +} + +type ContractType { + createdAt: DateTime! + description: String + id: ID! + name: String! + updatedAt: DateTime! +} + +type ContractTypeEdge { + cursor: String! + node: ContractType! +} + +input CreateApplicationInput { + coverLetter: String + cvId: ID + profileId: ID! + vacancyId: ID! +} + +input CreateCVInput { + profileId: String! + templateId: String! + title: String! +} + +input CreateProfileInput { + address: String + fullName: String + headline: String + linkedInUrl: String + locationId: String + name: String! + phone: String + postalCode: String + summary: String + website: String +} + +""" +A date string, such as 2007-12-03, compliant with the `full-date` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. +""" +scalar Date + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + +type DraftEducationType { + degree: String! + description: String + endDate: DateTime + fieldOfStudy: String + institution: DraftEntityType! + skills: [DraftEntityType!]! + startDate: DateTime! +} + +type DraftEntityType { + id: ID + name: String! +} + +type DraftJobExperienceType { + company: DraftEntityType! + description: String + endDate: DateTime + level: DraftEntityType! + role: DraftEntityType! + skills: [DraftEntityType!]! + startDate: DateTime! +} + +type Education { + createdAt: DateTime! + degree: String! + description: String + endDate: DateTime + fieldOfStudy: String + id: String! + institution: Institution! + profileId: String! + skills: [Skill!] + startDate: DateTime! + updatedAt: DateTime! +} + +type EducationConnection { + edges: [EducationEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EducationEdge { + cursor: String! + node: Education! +} + +type HealthResponse { + status: String! + timestamp: String! + timezone: String! + uptime: Float! +} + +type Institution { + createdAt: DateTime! + description: String + id: ID! + name: String! + updatedAt: DateTime! +} + +type InstitutionEdge { + cursor: String! + node: Institution! +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON + +type Level { + createdAt: DateTime! + description: String + id: ID! + name: String! + updatedAt: DateTime! +} + +type LevelConnection { + edges: [LevelEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type LevelEdge { + cursor: String! + node: Level! +} + +type Location { + code: String + createdAt: DateTime! + id: ID! + name: String! + parentId: ID + type: LocationType! + updatedAt: DateTime! +} + +enum LocationType { + CITY + COUNTRY + DISTRICT + PROVINCE +} + +type Membership { + id: String! + joinedAt: DateTime! + role: OrganizationRole! + user: User! +} + +type MembershipConnection { + edges: [MembershipEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type MembershipEdge { + cursor: String! + node: Membership! +} + +type Mutation { + addAiProvider(apiKey: String!, baseUrl: String, label: String!, model: String, providerType: String!, setActive: Boolean = false): UserAiProviderType! + addSkillsToJobExperience(experienceId: String!, skillIds: [String!]!): UserJobExperience! + addUserToOrganization(organizationId: String!, userId: String!): Boolean! + adminCreateLookupEntity(description: String, entityType: AdminEntityType!, name: String!): AdminLookupEntity! + adminDeleteLookupEntity(entityType: AdminEntityType!, id: String!): Boolean! + adminUpdateLookupEntity(description: String, entityType: AdminEntityType!, id: String!, name: String): AdminLookupEntity! + changePassword(currentPassword: String!, newPassword: String!): Boolean! + createApplication(input: CreateApplicationInput!): Application! + createCV(input: CreateCVInput!): CV! + createCompany(description: String, name: String!, website: String): Company! + createEducation(degree: String!, description: String, endDate: DateTime, fieldOfStudy: String, institutionId: String!, profileId: String!, skillIds: [String!], startDate: DateTime!): Education! + createInstitution(description: String, name: String!): Institution! + createJobExperience(companyId: String!, description: String, endDate: DateTime, levelId: String!, profileId: String!, roleId: String!, skillIds: [String!], startDate: DateTime!): UserJobExperience! + createLevel(description: String, name: String!): Level! + createOrganization(description: String, name: String!): Organization! + createProfile(input: CreateProfileInput!): ProfileType! + createRole(description: String, name: String!): Role! + createSkill(description: String, name: String!): Skill! + createVacancy(applicationUrl: String, companyId: String!, deadline: DateTime, description: String, isActive: Boolean, isPublic: Boolean, jobTypeId: String, levelId: String, locationId: String, maxSalary: Float, minSalary: Float, requirements: String, roleId: String!, title: String!): Vacancy! + deleteAccount(password: String!): Boolean! + deleteApplication(id: String!): Boolean! + deleteCV(id: String!): Boolean! + deleteCompany(id: String!): Boolean! + deleteEducation(id: String!): Boolean! + deleteJobExperience(id: String!): Boolean! + deleteLevel(id: String!): Boolean! + deleteOrganization(id: String!): Boolean! + deleteProfile(id: ID!): Boolean! + deleteRole(id: String!): Boolean! + deleteSession(sessionId: String!): Boolean! + deleteSkill(id: String!): Boolean! + deleteUserFile(id: String!): Boolean! + deleteVacancy(id: String!): Boolean! + generatePdf(cvId: String!): Boolean! + login(email: String!, password: String!): AuthenticationResponse! + logout: Boolean! + parseStory(storyText: String!): ParsedCVDataWithResolutionType! + register(email: String!, name: String!, password: String!): AuthenticationResponse! + removeAiProvider(providerId: String!): RemoveAiProviderResult! + removeSkillsFromJobExperience(experienceId: String!, skillIds: [String!]!): UserJobExperience! + removeUserFromOrganization(organizationId: String!, userId: String!): Boolean! + replaceAiProviderKey(newApiKey: String!, providerId: String!): UserAiProviderType! + requestPasswordReset(email: String!): Boolean! + resetOnboarding: Boolean! + resetPassword(newPassword: String!, token: String!): AuthenticationResponse! + sendVerificationEmail(email: String!): Boolean! + setActiveAiProvider(providerId: String!): AiSettings! + updateAiPreference(preference: AiPreference!): AiSettings! + updateAiProvider(baseUrl: String, label: String, model: String, providerId: String!): UserAiProviderType! + updateApplicationStatus(input: UpdateApplicationStatusInput!): Application! + updateCV(id: String!, input: UpdateCVInput!): CV! + updateCompany(description: String, id: String!, name: String, website: String): Company! + updateEducation(degree: String, description: String, endDate: DateTime, fieldOfStudy: String, id: String!, institutionId: String, skillIds: [String!], startDate: DateTime): Education! + updateJobExperience(companyId: String, description: String, endDate: DateTime, id: String!, levelId: String, roleId: String, skillIds: [String!], startDate: DateTime): UserJobExperience! + updateLevel(description: String, id: String!, name: String): Level! + updateOrganization(description: String, id: String!, name: String): Organization! + updateProfile(id: ID!, input: UpdateProfileInput!): ProfileType! + updateRole(description: String, id: String!, name: String): Role! + updateSkill(description: String, id: String!, name: String): Skill! + uploadFile(input: UploadFileInput!): UserFileType! + verifyEmail(email: String!, token: String!): AuthenticationResponse! +} + +type OnboardingStepResult { + blockedBy: [String!]! + dependsOn: [String!]! + name: String! + status: OnboardingStepStatus! +} + +"""Status of an onboarding step""" +enum OnboardingStepStatus { + COMPLETE + IN_PROGRESS + NOT_STARTED +} + +type Organization { + createdAt: DateTime! + description: String + id: ID! + memberCount: Float! + memberships(after: String, before: String, first: Int, last: Int, searchTerm: String, sortBy: String = "createdAt", sortOrder: String = "asc"): MembershipConnection! + name: String! + updatedAt: DateTime! +} + +type OrganizationRole { + color: String + createdAt: DateTime! + description: String + id: ID! + name: String! + updatedAt: DateTime! +} + +type PageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String +} + +type ParsedCVDataWithResolutionType { + education: [DraftEducationType!]! + jobExperiences: [DraftJobExperienceType!]! +} + +type PlatformAiStatus { + available: Boolean! +} + +type PlatformCapabilities { + aiParsing: Boolean! + availableProviderTypes: [String!]! + storyMode: Boolean! +} + +type ProfileType { + address: String + createdAt: DateTime! + cvs(after: String, before: String, first: Int, last: Int): CVConnection + educationHistory(after: String, before: String, first: Int, last: Int): EducationConnection + experience(after: String, before: String, first: Int, last: Int): UserJobExperienceConnection + fullName: String + headline: String + id: ID! + linkedInUrl: String + name: String! + phone: String + postalCode: String + summary: String + updatedAt: DateTime! + website: String +} + +type Query { + activeSessions: [ActiveSession!]! + adminLookupEntities(entityType: AdminEntityType!, searchTerm: String): [AdminLookupEntity!]! + aiCallLog(limit: Int, providerName: String, status: String): [AiCallLogEntryType!]! + aiCallLogHistory(limit: Int, providerName: String, status: String): [AiCallLogEntryType!]! + application(id: String!): Application + companies(after: String, before: String, first: Int, last: Int, searchTerm: String): CompanyConnection! + company(id: String!): Company! + cv(id: String!): CV + cvTemplate(id: String!): CVTemplate + cvTemplates(after: String, before: String, first: Int, last: Int): CVTemplateConnection! + health: HealthResponse! + institutions: [Institution!]! + level(id: String!): Level! + levels(after: String, before: String, first: Int, last: Int, searchTerm: String): LevelConnection! + me: User! + myAiProviders: [UserAiProviderType!]! + myAiSettings: AiSettings! + myProfiles: [ProfileType!]! + myUserFiles: [UserFileType!]! + onboardingStatus: [OnboardingStepResult!]! + organization(id: String!): Organization + platformAiAvailable: PlatformAiStatus! + platformCapabilities: PlatformCapabilities! + profile(id: ID!): ProfileType + queueMessages(limit: Int): QueueMessageConnection! + queueStats: QueueStats! + renderCV(cvId: String!): RenderedCV! + role(id: String!): Role! + roles(after: String, before: String, first: Int, last: Int, searchTerm: String): RoleConnection! + skill(id: String!): Skill! + skills(after: String, before: String, first: Int, last: Int, searchTerm: String): SkillConnection! + systemStatus: SystemStatus! + userFile(id: String!): UserFileType + workerHealth: [WorkerHealth!]! +} + +type QueueMessage { + createdAt: DateTime! + id: ID! + messageName: String + queueName: String! + status: String! +} + +type QueueMessageConnection { + edges: [QueueMessageEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type QueueMessageEdge { + cursor: String! + node: QueueMessage! +} + +type QueueStats { + oldestPendingSeconds: Float + pending: Int! + processing: Int! + scheduled: Int! +} + +type RateType { + createdAt: DateTime! + description: String + id: ID! + name: String! + updatedAt: DateTime! +} + +type RateTypeEdge { + cursor: String! + node: RateType! +} + +type RemoveAiProviderResult { + newPreference: AiPreference! + success: Boolean! + wasActive: Boolean! +} + +type RenderedCV { + html: String! +} + +type Role { + createdAt: DateTime! + description: String + id: ID! + name: String! + updatedAt: DateTime! +} + +type RoleConnection { + edges: [RoleEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type RoleEdge { + cursor: String! + node: Role! +} + +type Skill { + createdAt: DateTime! + description: String + id: ID! + name: String! + updatedAt: DateTime! +} + +type SkillConnection { + edges: [SkillEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type SkillEdge { + cursor: String! + node: Skill! +} + +type SystemStatus { + platformProvider: AiProviderStatus + registeredProviderTypes: [String!]! + + """Server uptime in seconds""" + serverUptime: Float! +} + +type TokenExpiration { + expiresAt: DateTime! + expiresInSeconds: Float! +} + +input UpdateApplicationStatusInput { + applicationId: ID! + statusId: ID! +} + +input UpdateCVInput { + introduction: String + templateId: String + title: String +} + +input UpdateProfileInput { + address: String + fullName: String + headline: String + linkedInUrl: String + locationId: String + name: String + phone: String + postalCode: String + summary: String + website: String +} + +input UploadFileInput { + """Base64-encoded file content""" + content: String! + fileName: String! + mimeType: String! + + """Target profile. If omitted, auto-creates a Default profile.""" + profileId: ID +} + +type User { + applications: ApplicationConnection + createdAt: DateTime! + cvs(after: String, before: String, first: Int, last: Int): CVConnection + educationHistory(after: String, before: String, first: Int, last: Int): EducationConnection + email: String + emailVerifiedAt: DateTime + experience(after: String, before: String, first: Int, last: Int): UserJobExperienceConnection + id: ID! + name: String! + organizations: [Organization!] + role: UserRole! + vacancies(after: String, before: String, filter: VacancyFilterInput, first: Int, last: Int): VacancyConnection +} + +type UserAiProviderType { + baseUrl: String + createdAt: DateTime! + id: String! + label: String! + maskedApiKey: String! + model: String + providerType: String! +} + +type UserFileType { + createdAt: DateTime! + error: String + fileName: String! + id: String! + isDuplicate: Boolean! + mimeType: String! + profileId: String! + result: JSON + sizeBytes: Int! + source: String! + status: String! + statusMessage: String! + updatedAt: DateTime! +} + +type UserJobExperience { + company: Company! + createdAt: DateTime! + description: String + endDate: DateTime + id: ID! + level: Level! + profileId: String! + role: Role! + skills: [Skill!]! + startDate: DateTime! + updatedAt: DateTime! +} + +type UserJobExperienceConnection { + edges: [UserJobExperienceEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type UserJobExperienceEdge { + cursor: String! + node: UserJobExperience! +} + +enum UserRole { + ADMIN + USER +} + +type Vacancy { + applicationUrl: String + company: Company! + contractType: ContractType + createdAt: DateTime! + deadline: DateTime + description: String + id: ID! + isActive: Boolean! + isPublic: Boolean! + level: Level + location: Location + maxSalary: Float + minSalary: Float + ownerId: String! + rateType: RateType + requirements: String + role: Role! + skills: [Skill!] + title: String! + updatedAt: DateTime! +} + +type VacancyConnection { + edges: [VacancyEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type VacancyEdge { + cursor: String! + node: Vacancy! +} + +input VacancyFilterInput { + companies: [String!] + isPublic: Boolean + jobTypes: [String!] + levels: [String!] + locations: [String!] + maxSalary: Int + minSalary: Int + searchTerm: String +} + +type WorkerHealth { + lastSeenAt: DateTime! + startedAt: DateTime! + status: String! + workerId: String! +} \ No newline at end of file diff --git a/apps/api/src/modules/app.module.ts b/apps/api/src/modules/app.module.ts index e25bbe5..a0dcfcc 100644 --- a/apps/api/src/modules/app.module.ts +++ b/apps/api/src/modules/app.module.ts @@ -21,6 +21,7 @@ import { GraphQLModule } from "@nestjs/graphql"; import { JwtModule } from "@nestjs/jwt"; import { ThrottlerModule } from "@nestjs/throttler"; import { Request, Response } from "express"; +import { join } from "node:path"; import { AuthContextMiddleware } from "@/config/auth-context.middleware"; import { AppConfigModule } from "@/config/config.module"; import { validate } from "@/config/env.validation"; @@ -90,7 +91,10 @@ import { VacancyModule } from "./vacancies/vacancy.module"; ]), GraphQLModule.forRoot({ driver: ApolloDriver, - autoSchemaFile: true, + autoSchemaFile: + process.env["NODE_ENV"] === "production" + ? true + : join(__dirname, "../../schema.gql"), sortSchema: true, context: ({ req, res }: { req: Request; res: Response }) => ({ req, diff --git a/apps/client/codegen.ts b/apps/client/codegen.ts index 2165705..b5d3a32 100644 --- a/apps/client/codegen.ts +++ b/apps/client/codegen.ts @@ -1,7 +1,7 @@ import type { CodegenConfig } from "@graphql-codegen/cli"; const config: CodegenConfig = { - schema: process.env.GRAPHQL_SCHEMA_URL || "http://localhost:3000/graphql", + schema: process.env.GRAPHQL_SCHEMA_URL || "../api/schema.gql", documents: ["src/**/*.graphql"], generates: { "./src/generated/graphql.ts": { diff --git a/apps/client/package.json b/apps/client/package.json index 0ea56a4..a9972f3 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -12,7 +12,8 @@ "start": "vite", "typecheck": "tsc -b --noEmit", "codegen": "graphql-codegen --config codegen.ts", - "codegen:client": "graphql-codegen --config codegen.ts" + "codegen:client": "graphql-codegen --config codegen.ts", + "prepare": "test -f src/generated/graphql.ts || pnpm codegen" }, "dependencies": { "@cv/routing": "*", -- 2.51.2