@open-mercato/onboarding 0.4.2-canary-c02407ff85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/build.mjs +62 -0
  2. package/dist/index.js +1 -0
  3. package/dist/index.js.map +7 -0
  4. package/dist/modules/onboarding/acl.js +11 -0
  5. package/dist/modules/onboarding/acl.js.map +7 -0
  6. package/dist/modules/onboarding/api/get/onboarding/verify.js +208 -0
  7. package/dist/modules/onboarding/api/get/onboarding/verify.js.map +7 -0
  8. package/dist/modules/onboarding/api/post/onboarding.js +193 -0
  9. package/dist/modules/onboarding/api/post/onboarding.js.map +7 -0
  10. package/dist/modules/onboarding/data/entities.js +84 -0
  11. package/dist/modules/onboarding/data/entities.js.map +7 -0
  12. package/dist/modules/onboarding/data/validators.js +27 -0
  13. package/dist/modules/onboarding/data/validators.js.map +7 -0
  14. package/dist/modules/onboarding/emails/AdminNotificationEmail.js +18 -0
  15. package/dist/modules/onboarding/emails/AdminNotificationEmail.js.map +7 -0
  16. package/dist/modules/onboarding/emails/VerificationEmail.js +36 -0
  17. package/dist/modules/onboarding/emails/VerificationEmail.js.map +7 -0
  18. package/dist/modules/onboarding/frontend/onboarding/page.js +279 -0
  19. package/dist/modules/onboarding/frontend/onboarding/page.js.map +7 -0
  20. package/dist/modules/onboarding/index.js +14 -0
  21. package/dist/modules/onboarding/index.js.map +7 -0
  22. package/dist/modules/onboarding/lib/service.js +83 -0
  23. package/dist/modules/onboarding/lib/service.js.map +7 -0
  24. package/dist/modules/onboarding/migrations/Migration20260112142945.js +12 -0
  25. package/dist/modules/onboarding/migrations/Migration20260112142945.js.map +7 -0
  26. package/generated/entities/onboarding_request/index.ts +19 -0
  27. package/generated/entities.ids.generated.ts +11 -0
  28. package/generated/entity-fields-registry.ts +11 -0
  29. package/jest.config.cjs +19 -0
  30. package/package.json +83 -0
  31. package/src/index.ts +2 -0
  32. package/src/modules/onboarding/acl.ts +7 -0
  33. package/src/modules/onboarding/api/get/onboarding/verify.ts +224 -0
  34. package/src/modules/onboarding/api/post/onboarding.ts +210 -0
  35. package/src/modules/onboarding/data/entities.ts +67 -0
  36. package/src/modules/onboarding/data/validators.ts +27 -0
  37. package/src/modules/onboarding/emails/AdminNotificationEmail.tsx +32 -0
  38. package/src/modules/onboarding/emails/VerificationEmail.tsx +54 -0
  39. package/src/modules/onboarding/frontend/onboarding/page.tsx +305 -0
  40. package/src/modules/onboarding/i18n/de.json +49 -0
  41. package/src/modules/onboarding/i18n/en.json +49 -0
  42. package/src/modules/onboarding/i18n/es.json +49 -0
  43. package/src/modules/onboarding/i18n/pl.json +49 -0
  44. package/src/modules/onboarding/index.ts +12 -0
  45. package/src/modules/onboarding/lib/service.ts +90 -0
  46. package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +230 -0
  47. package/src/modules/onboarding/migrations/Migration20260112142945.ts +11 -0
  48. package/tsconfig.build.json +4 -0
  49. package/tsconfig.json +9 -0
  50. package/watch.mjs +6 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/onboarding/migrations/Migration20260112142945.ts"],
4
+ "sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260112142945 extends Migration {\n\n override async up(): Promise<void> {\n this.addSql(`create table \"onboarding_requests\" (\"id\" uuid not null default gen_random_uuid(), \"email\" text not null, \"token_hash\" text not null, \"status\" text not null default 'pending', \"first_name\" text not null, \"last_name\" text not null, \"organization_name\" text not null, \"locale\" text null, \"terms_accepted\" boolean not null default false, \"password_hash\" text null, \"expires_at\" timestamptz not null, \"completed_at\" timestamptz null, \"tenant_id\" uuid null, \"organization_id\" uuid null, \"user_id\" uuid null, \"last_email_sent_at\" timestamptz null, \"created_at\" timestamptz not null, \"updated_at\" timestamptz null, \"deleted_at\" timestamptz null, constraint \"onboarding_requests_pkey\" primary key (\"id\"));`);\n this.addSql(`alter table \"onboarding_requests\" add constraint \"onboarding_requests_token_hash_unique\" unique (\"token_hash\");`);\n this.addSql(`alter table \"onboarding_requests\" add constraint \"onboarding_requests_email_unique\" unique (\"email\");`);\n }\n\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,gCAAgC,UAAU;AAAA,EAErD,MAAe,KAAoB;AACjC,SAAK,OAAO,0rBAA0rB;AACtsB,SAAK,OAAO,iHAAiH;AAC7H,SAAK,OAAO,uGAAuG;AAAA,EACrH;AAEF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,19 @@
1
+ export const id = 'id'
2
+ export const email = 'email'
3
+ export const token_hash = 'token_hash'
4
+ export const status = 'status'
5
+ export const first_name = 'first_name'
6
+ export const last_name = 'last_name'
7
+ export const organization_name = 'organization_name'
8
+ export const locale = 'locale'
9
+ export const terms_accepted = 'terms_accepted'
10
+ export const password_hash = 'password_hash'
11
+ export const expires_at = 'expires_at'
12
+ export const completed_at = 'completed_at'
13
+ export const tenant_id = 'tenant_id'
14
+ export const organization_id = 'organization_id'
15
+ export const user_id = 'user_id'
16
+ export const last_email_sent_at = 'last_email_sent_at'
17
+ export const created_at = 'created_at'
18
+ export const updated_at = 'updated_at'
19
+ export const deleted_at = 'deleted_at'
@@ -0,0 +1,11 @@
1
+ // AUTO-GENERATED by mercato generate entity-ids
2
+ export const M = {
3
+ "onboarding": "onboarding"
4
+ } as const
5
+ export const E = {
6
+ "onboarding": {
7
+ "onboarding_request": "onboarding:onboarding_request"
8
+ }
9
+ } as const
10
+ export type KnownModuleId = keyof typeof M
11
+ export type KnownEntities = typeof E
@@ -0,0 +1,11 @@
1
+ // AUTO-GENERATED by mercato generate entity-ids
2
+ // Static registry for entity fields - eliminates dynamic imports for Turbopack compatibility
3
+ import * as onboarding_request from './entities/onboarding_request/index'
4
+
5
+ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
6
+ onboarding_request
7
+ }
8
+
9
+ export function getEntityFields(slug: string): Record<string, string> | undefined {
10
+ return entityFieldsRegistry[slug]
11
+ }
@@ -0,0 +1,19 @@
1
+ /** @type {import('jest').Config} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+ rootDir: '.',
6
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
7
+ transform: {
8
+ '^.+\\.(t|j)sx?$': [
9
+ 'ts-jest',
10
+ {
11
+ tsconfig: {
12
+ jsx: 'react-jsx',
13
+ },
14
+ },
15
+ ],
16
+ },
17
+ testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
18
+ passWithNoTests: true,
19
+ }
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@open-mercato/onboarding",
3
+ "version": "0.4.2-canary-c02407ff85",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "scripts": {
7
+ "build": "node build.mjs",
8
+ "watch": "node watch.mjs",
9
+ "test": "jest --config jest.config.cjs",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "exports": {
13
+ ".": "./dist/index.js",
14
+ "./*.ts": {
15
+ "types": "./src/*.ts",
16
+ "default": "./dist/*.js"
17
+ },
18
+ "./*.tsx": {
19
+ "types": "./src/*.tsx",
20
+ "default": "./dist/*.js"
21
+ },
22
+ "./*.json": "./src/*.json",
23
+ "./*": {
24
+ "types": [
25
+ "./src/*.ts",
26
+ "./src/*.tsx"
27
+ ],
28
+ "default": "./dist/*.js"
29
+ },
30
+ "./*/*.json": "./src/*/*.json",
31
+ "./*/*": {
32
+ "types": [
33
+ "./src/*/*.ts",
34
+ "./src/*/*.tsx"
35
+ ],
36
+ "default": "./dist/*/*.js"
37
+ },
38
+ "./*/*/*.json": "./src/*/*/*.json",
39
+ "./*/*/*": {
40
+ "types": [
41
+ "./src/*/*/*.ts",
42
+ "./src/*/*/*.tsx"
43
+ ],
44
+ "default": "./dist/*/*/*.js"
45
+ },
46
+ "./*/*/*/*.json": "./src/*/*/*/*.json",
47
+ "./*/*/*/*": {
48
+ "types": [
49
+ "./src/*/*/*/*.ts",
50
+ "./src/*/*/*/*.tsx"
51
+ ],
52
+ "default": "./dist/*/*/*/*.js"
53
+ },
54
+ "./*/*/*/*/*.json": "./src/*/*/*/*/*.json",
55
+ "./*/*/*/*/*": {
56
+ "types": [
57
+ "./src/*/*/*/*/*.ts",
58
+ "./src/*/*/*/*/*.tsx"
59
+ ],
60
+ "default": "./dist/*/*/*/*/*.js"
61
+ },
62
+ "./*/*/*/*/*/*.json": "./src/*/*/*/*/*/*.json",
63
+ "./*/*/*/*/*/*": {
64
+ "types": [
65
+ "./src/*/*/*/*/*/*.ts",
66
+ "./src/*/*/*/*/*/*.tsx"
67
+ ],
68
+ "default": "./dist/*/*/*/*/*/*.js"
69
+ }
70
+ },
71
+ "dependencies": {
72
+ "@open-mercato/shared": "0.4.2-canary-c02407ff85"
73
+ },
74
+ "devDependencies": {
75
+ "@types/jest": "^30.0.0",
76
+ "jest": "^30.2.0",
77
+ "ts-jest": "^29.4.6"
78
+ },
79
+ "publishConfig": {
80
+ "access": "public"
81
+ },
82
+ "stableVersion": "0.4.1"
83
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Barrel for @open-mercato/onboarding
2
+ export {}
@@ -0,0 +1,7 @@
1
+ export const features = [
2
+ { id: 'onboarding.access', title: 'Access onboarding flow', module: 'onboarding' },
3
+ { id: 'onboarding.submit', title: 'Submit onboarding request', module: 'onboarding' },
4
+ { id: 'onboarding.verify', title: 'Verify onboarding request', module: 'onboarding' },
5
+ ]
6
+
7
+ export default features
@@ -0,0 +1,224 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
+ import { onboardingVerifySchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'
6
+ import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
7
+ import { setupInitialTenant } from '@open-mercato/core/modules/auth/lib/setup-app'
8
+ import {
9
+ seedCustomerDictionaries,
10
+ seedCustomerExamples,
11
+ seedCurrencyDictionary,
12
+ } from '@open-mercato/core/modules/customers/cli'
13
+ import { seedDashboardDefaultsForTenant } from '@open-mercato/core/modules/dashboards/cli'
14
+ import { AuthService } from '@open-mercato/core/modules/auth/services/authService'
15
+ import { signJwt } from '@open-mercato/shared/lib/auth/jwt'
16
+ import { reindexEntity } from '@open-mercato/core/modules/query_index/lib/reindexer'
17
+ import { purgeIndexScope } from '@open-mercato/core/modules/query_index/lib/purge'
18
+ import { refreshCoverageSnapshot } from '@open-mercato/core/modules/query_index/lib/coverage'
19
+ import { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'
20
+ import { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
21
+ import type { VectorIndexService } from '@open-mercato/search/vector'
22
+ import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
23
+
24
+ export const metadata = {
25
+ GET: {
26
+ requireAuth: false,
27
+ },
28
+ }
29
+
30
+ function redirectWithStatus(baseUrl: string, status: string) {
31
+ return NextResponse.redirect(`${baseUrl}/onboarding?status=${encodeURIComponent(status)}`)
32
+ }
33
+
34
+ export async function GET(req: Request) {
35
+ const url = new URL(req.url)
36
+ const baseUrl = process.env.APP_URL || `${url.protocol}//${url.host}`
37
+ const token = url.searchParams.get('token') ?? ''
38
+ const parsed = onboardingVerifySchema.safeParse({ token })
39
+ if (!parsed.success) {
40
+ return redirectWithStatus(baseUrl, 'invalid')
41
+ }
42
+
43
+ const container = await createRequestContainer()
44
+ const em = (container.resolve('em') as EntityManager)
45
+ const service = new OnboardingService(em)
46
+ const request = await service.findPendingByToken(parsed.data.token)
47
+ if (!request) {
48
+ return redirectWithStatus(baseUrl, 'invalid')
49
+ }
50
+ if (!request.passwordHash) {
51
+ console.error('[onboarding.verify] missing password hash for request', request.id)
52
+ return redirectWithStatus(baseUrl, 'error')
53
+ }
54
+
55
+ let tenantId: string | null = null
56
+ let organizationId: string | null = null
57
+ let userId: string | null = null
58
+
59
+ try {
60
+ const setupResult = await setupInitialTenant(em, {
61
+ orgName: request.organizationName,
62
+ includeDerivedUsers: false,
63
+ failIfUserExists: true,
64
+ primaryUserRoles: ['admin'],
65
+ includeSuperadminRole: false,
66
+ primaryUser: {
67
+ email: request.email,
68
+ firstName: request.firstName,
69
+ lastName: request.lastName,
70
+ displayName: `${request.firstName} ${request.lastName}`.trim(),
71
+ hashedPassword: request.passwordHash,
72
+ confirm: true,
73
+ },
74
+ })
75
+
76
+ tenantId = String(setupResult.tenantId)
77
+ organizationId = String(setupResult.organizationId)
78
+
79
+ const mainUserSnapshot = setupResult.users.find((entry) => entry.user.email === request.email)
80
+ if (!mainUserSnapshot) throw new Error('USER_NOT_CREATED')
81
+ const user = mainUserSnapshot.user
82
+ const resolvedUserId = String(user.id)
83
+ userId = resolvedUserId
84
+
85
+ await seedCustomerDictionaries(em, { tenantId, organizationId })
86
+ await seedCurrencyDictionary(em, { tenantId, organizationId })
87
+ await seedCustomerExamples(em, container, { tenantId, organizationId })
88
+ await seedDashboardDefaultsForTenant(em, { tenantId, organizationId, logger: () => {} })
89
+
90
+ if (tenantId) {
91
+ let vectorService: VectorIndexService | null = null
92
+ try {
93
+ vectorService = container.resolve<VectorIndexService>('vectorIndexService')
94
+ } catch {
95
+ vectorService = null
96
+ }
97
+ const coverageRefreshKeys = new Set<string>()
98
+ try {
99
+ const allEntities = getEntityIds()
100
+ const entityIds = flattenSystemEntityIds(allEntities)
101
+ for (const entityType of entityIds) {
102
+ try {
103
+ await purgeIndexScope(em, { entityType, tenantId })
104
+ } catch (error) {
105
+ console.error('[onboarding.verify] failed to purge query index scope', { entityType, tenantId, error })
106
+ }
107
+ try {
108
+ await reindexEntity(em, {
109
+ entityType,
110
+ tenantId,
111
+ force: true,
112
+ emitVectorizeEvents: false,
113
+ vectorService: null,
114
+ })
115
+ } catch (error) {
116
+ console.error('[onboarding.verify] failed to reindex entity', { entityType, tenantId, error })
117
+ }
118
+ coverageRefreshKeys.add(`${entityType}|${tenantId}|__null__`)
119
+ if (organizationId) coverageRefreshKeys.add(`${entityType}|${tenantId}|${organizationId}`)
120
+ }
121
+ } catch (error) {
122
+ console.error('[onboarding.verify] failed to rebuild query indexes', { tenantId, error })
123
+ }
124
+
125
+ if (vectorService) {
126
+ try {
127
+ await vectorService.reindexAll({ tenantId, organizationId, purgeFirst: true })
128
+ } catch (error) {
129
+ console.error('[onboarding.verify] failed to rebuild vector indexes', { tenantId, organizationId, error })
130
+ }
131
+ }
132
+
133
+ if (coverageRefreshKeys.size) {
134
+ for (const entry of coverageRefreshKeys) {
135
+ const [entityType, tenantKey, orgKey] = entry.split('|')
136
+ const orgScope = orgKey === '__null__' ? null : orgKey
137
+ try {
138
+ await refreshCoverageSnapshot(
139
+ em,
140
+ {
141
+ entityType,
142
+ tenantId: tenantKey,
143
+ organizationId: orgScope,
144
+ withDeleted: false,
145
+ },
146
+ )
147
+ } catch (error) {
148
+ console.error('[onboarding.verify] failed to refresh coverage snapshot', {
149
+ entityType,
150
+ tenantId: tenantKey,
151
+ organizationId: orgScope,
152
+ error,
153
+ })
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ const authService = (container.resolve('authService') as AuthService)
160
+ await authService.updateLastLoginAt(user)
161
+ const roles = await authService.getUserRoles(user, tenantId)
162
+ const jwt = signJwt({
163
+ sub: String(user.id),
164
+ tenantId,
165
+ orgId: organizationId,
166
+ email: user.email,
167
+ roles,
168
+ })
169
+ const response = NextResponse.redirect(`${baseUrl}/backend`)
170
+ response.cookies.set('auth_token', jwt, {
171
+ httpOnly: true,
172
+ sameSite: 'lax',
173
+ secure: process.env.NODE_ENV === 'production',
174
+ path: '/',
175
+ maxAge: 60 * 60 * 8,
176
+ })
177
+
178
+ const rememberDays = Number(process.env.REMEMBER_ME_DAYS || '30')
179
+ const expiresAt = new Date(Date.now() + rememberDays * 24 * 60 * 60 * 1000)
180
+ const session = await authService.createSession(user, expiresAt)
181
+ response.cookies.set('session_token', session.token, {
182
+ httpOnly: true,
183
+ sameSite: 'lax',
184
+ secure: process.env.NODE_ENV === 'production',
185
+ path: '/',
186
+ expires: expiresAt,
187
+ })
188
+
189
+ await service.markCompleted(request, { tenantId, organizationId, userId: resolvedUserId })
190
+ return response
191
+ } catch (error) {
192
+ if (error instanceof Error && error.message === 'USER_EXISTS') {
193
+ return redirectWithStatus(baseUrl, 'already_exists')
194
+ }
195
+ console.error('[onboarding.verify] failed', error)
196
+ return redirectWithStatus(baseUrl, 'error')
197
+ }
198
+ }
199
+
200
+ export default GET
201
+
202
+ const onboardingTag = 'Onboarding'
203
+
204
+ const onboardingVerifyQuerySchema = z.object({
205
+ token: onboardingVerifySchema.shape.token,
206
+ })
207
+
208
+ const onboardingVerifyDoc: OpenApiMethodDoc = {
209
+ summary: 'Verify onboarding token',
210
+ description: 'Validates the onboarding token, provisions the tenant, seeds demo data, and redirects the user to the dashboard.',
211
+ tags: [onboardingTag],
212
+ query: onboardingVerifyQuerySchema,
213
+ responses: [
214
+ { status: 302, description: 'Redirect to onboarding UI or dashboard' },
215
+ ],
216
+ }
217
+
218
+ export const openApi: OpenApiRouteDoc = {
219
+ tag: onboardingTag,
220
+ summary: 'Onboarding verification redirect',
221
+ methods: {
222
+ GET: onboardingVerifyDoc,
223
+ },
224
+ }
@@ -0,0 +1,210 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
+ import { loadDictionary } from '@open-mercato/shared/lib/i18n/server'
6
+ import { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
7
+ import { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate'
8
+ import { sendEmail } from '@open-mercato/shared/lib/email/send'
9
+ import { onboardingStartSchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'
10
+ import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
11
+ import VerificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/VerificationEmail'
12
+ import AdminNotificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/AdminNotificationEmail'
13
+ import { User } from '@open-mercato/core/modules/auth/data/entities'
14
+ import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
15
+
16
+ export const metadata = {
17
+ POST: {
18
+ requireAuth: false,
19
+ },
20
+ }
21
+
22
+ export async function POST(req: Request) {
23
+ if (process.env.SELF_SERVICE_ONBOARDING_ENABLED !== 'true') {
24
+ return NextResponse.json({ ok: false, error: 'Self-service onboarding is disabled.' }, { status: 404 })
25
+ }
26
+ let payload: unknown
27
+ try {
28
+ payload = await req.json()
29
+ } catch {
30
+ return NextResponse.json({ ok: false, error: 'Invalid payload' }, { status: 400 })
31
+ }
32
+
33
+ const rawLocale =
34
+ payload && typeof payload === 'object' && 'locale' in payload && typeof (payload as any).locale === 'string'
35
+ ? (payload as any).locale as string
36
+ : null
37
+ const locale: Locale = rawLocale && locales.includes(rawLocale as Locale)
38
+ ? (rawLocale as Locale)
39
+ : defaultLocale
40
+ const dict = await loadDictionary(locale)
41
+ const translate = createFallbackTranslator(dict)
42
+
43
+ const parsed = onboardingStartSchema.safeParse(payload)
44
+ if (!parsed.success) {
45
+ const fieldErrors: Record<string, string> = {}
46
+ for (const issue of parsed.error.issues) {
47
+ const path = issue.path[0]
48
+ if (!path) continue
49
+ switch (path) {
50
+ case 'email':
51
+ fieldErrors.email = translate('onboarding.errors.emailInvalid', 'Enter a valid work email.')
52
+ break
53
+ case 'firstName':
54
+ fieldErrors.firstName = translate('onboarding.errors.firstNameRequired', 'First name is required.')
55
+ break
56
+ case 'lastName':
57
+ fieldErrors.lastName = translate('onboarding.errors.lastNameRequired', 'Last name is required.')
58
+ break
59
+ case 'organizationName':
60
+ fieldErrors.organizationName = translate('onboarding.errors.organizationNameRequired', 'Organization name is required.')
61
+ break
62
+ case 'password':
63
+ fieldErrors.password = translate('onboarding.errors.passwordRequired', 'Password must be at least 6 characters.')
64
+ break
65
+ case 'confirmPassword':
66
+ fieldErrors.confirmPassword = translate('onboarding.errors.passwordMismatch', 'Passwords must match.')
67
+ break
68
+ case 'termsAccepted':
69
+ fieldErrors.termsAccepted = translate('onboarding.form.termsRequired', 'Please accept the terms to continue.')
70
+ break
71
+ default:
72
+ break
73
+ }
74
+ }
75
+ return NextResponse.json({
76
+ ok: false,
77
+ error: translate('onboarding.form.genericError', 'Please check the form and try again.'),
78
+ fieldErrors,
79
+ }, { status: 400 })
80
+ }
81
+
82
+ try {
83
+ const container = await createRequestContainer()
84
+ const em = (container.resolve('em') as EntityManager)
85
+
86
+ const existingUser = await em.findOne(User, { email: parsed.data.email })
87
+ if (existingUser) {
88
+ const message = translate('onboarding.errors.emailExists', 'We already have an account with this email. Try signing in or resetting your password.')
89
+ return NextResponse.json({
90
+ ok: false,
91
+ error: message,
92
+ fieldErrors: { email: message },
93
+ }, { status: 409 })
94
+ }
95
+
96
+ const service = new OnboardingService(em)
97
+ let request, token
98
+ try {
99
+ const result = await service.createOrUpdateRequest(parsed.data)
100
+ request = result.request
101
+ token = result.token
102
+ } catch (err) {
103
+ if (err instanceof Error && err.message.startsWith('PENDING_REQUEST:')) {
104
+ const minutes = Number(err.message.split(':')[1] || '10')
105
+ const message = translate('onboarding.errors.pendingRequest', 'We already have a pending verification. Please try again in about {minutes} minutes or contact the administrator.', { minutes })
106
+ return NextResponse.json({
107
+ ok: false,
108
+ error: message,
109
+ fieldErrors: { email: message },
110
+ }, { status: 409 })
111
+ }
112
+ throw err
113
+ }
114
+
115
+ const url = new URL(req.url)
116
+ const baseUrl = process.env.APP_URL || `${url.protocol}//${url.host}`
117
+ const verifyUrl = `${baseUrl}/api/onboarding/onboarding/verify?token=${token}`
118
+
119
+ const firstName = request.firstName || parsed.data.firstName
120
+ const subject = translate('onboarding.email.subject', 'Confirm your email to finish onboarding')
121
+ const emailCopy = {
122
+ preview: translate('onboarding.email.preview', 'Confirm your email to activate your Open Mercato workspace'),
123
+ heading: translate('onboarding.email.heading', 'Welcome to Open Mercato'),
124
+ greeting: translate('onboarding.email.greeting', 'Hi {firstName},', { firstName }),
125
+ body: translate(
126
+ 'onboarding.email.body',
127
+ 'We just need to confirm your email address to finish setting up the organization {organizationName}.',
128
+ { organizationName: request.organizationName },
129
+ ),
130
+ cta: translate('onboarding.email.cta', 'Confirm email & activate workspace'),
131
+ expiry: translate(
132
+ 'onboarding.email.expiry',
133
+ "The link will expire in 24 hours. If you didn't request this, you can safely ignore this message.",
134
+ ),
135
+ footer: translate('onboarding.email.footer', 'Open Mercato · Tenant onboarding service'),
136
+ }
137
+ const emailReact = VerificationEmail({ verifyUrl, copy: emailCopy })
138
+ await sendEmail({ to: request.email, subject, react: emailReact })
139
+
140
+ const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'
141
+ const adminSubject = translate('onboarding.email.adminSubject', 'New self-service onboarding request')
142
+ const adminCopy = {
143
+ preview: translate('onboarding.email.adminPreview', 'New onboarding request submitted'),
144
+ heading: translate('onboarding.email.adminHeading', 'New onboarding request'),
145
+ body: translate('onboarding.email.adminBody', '{firstName} {lastName} ({email}) submitted an onboarding request for {organizationName}.', {
146
+ firstName: request.firstName,
147
+ lastName: request.lastName,
148
+ email: request.email,
149
+ organizationName: request.organizationName,
150
+ }),
151
+ footer: translate('onboarding.email.adminFooter', 'You can review the tenant after verification is complete.'),
152
+ }
153
+ await sendEmail({
154
+ to: adminEmail,
155
+ subject: adminSubject,
156
+ react: AdminNotificationEmail({ copy: adminCopy }),
157
+ })
158
+
159
+ return NextResponse.json({ ok: true, email: request.email })
160
+ } catch (error) {
161
+ console.error('[onboarding.start] failed', error)
162
+ return NextResponse.json({
163
+ ok: false,
164
+ error: translate('onboarding.form.genericError', 'Something went wrong. Please try again later.'),
165
+ }, { status: 500 })
166
+ }
167
+ }
168
+
169
+ export default POST
170
+
171
+ const onboardingTag = 'Onboarding'
172
+
173
+ const onboardingSuccessSchema = z.object({
174
+ ok: z.literal(true),
175
+ email: z.string().email(),
176
+ })
177
+
178
+ const onboardingErrorSchema = z.object({
179
+ ok: z.literal(false),
180
+ error: z.string(),
181
+ fieldErrors: z.record(z.string(), z.string()).optional(),
182
+ })
183
+
184
+ const onboardingPostDoc: OpenApiMethodDoc = {
185
+ summary: 'Submit onboarding request',
186
+ description: 'Accepts a self-service onboarding form submission and triggers email verification.',
187
+ tags: [onboardingTag],
188
+ requestBody: {
189
+ contentType: 'application/json',
190
+ schema: onboardingStartSchema,
191
+ description: 'Onboarding form payload with contact and organization information.',
192
+ },
193
+ responses: [
194
+ { status: 200, description: 'Onboarding request accepted.', schema: onboardingSuccessSchema },
195
+ ],
196
+ errors: [
197
+ { status: 400, description: 'Validation failed', schema: onboardingErrorSchema },
198
+ { status: 404, description: 'Self-service onboarding disabled', schema: onboardingErrorSchema },
199
+ { status: 409, description: 'Existing account or pending request', schema: onboardingErrorSchema },
200
+ { status: 500, description: 'Unexpected server error', schema: onboardingErrorSchema },
201
+ ],
202
+ }
203
+
204
+ export const openApi: OpenApiRouteDoc = {
205
+ tag: onboardingTag,
206
+ summary: 'Self-service onboarding submission',
207
+ methods: {
208
+ POST: onboardingPostDoc,
209
+ },
210
+ }
@@ -0,0 +1,67 @@
1
+ import { Entity, PrimaryKey, Property, Unique } from '@mikro-orm/core'
2
+
3
+ type OnboardingStatus = 'pending' | 'completed' | 'expired'
4
+
5
+ @Entity({ tableName: 'onboarding_requests' })
6
+ @Unique({ properties: ['email'] })
7
+ @Unique({ properties: ['tokenHash'] })
8
+ export class OnboardingRequest {
9
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
10
+ id!: string
11
+
12
+ @Property({ type: 'text' })
13
+ email!: string
14
+
15
+ @Property({ name: 'token_hash', type: 'text' })
16
+ tokenHash!: string
17
+
18
+ @Property({ type: 'text', default: 'pending' })
19
+ status: OnboardingStatus = 'pending'
20
+
21
+ @Property({ name: 'first_name', type: 'text' })
22
+ firstName!: string
23
+
24
+ @Property({ name: 'last_name', type: 'text' })
25
+ lastName!: string
26
+
27
+ @Property({ name: 'organization_name', type: 'text' })
28
+ organizationName!: string
29
+
30
+ @Property({ type: 'text', nullable: true })
31
+ locale?: string | null
32
+
33
+ @Property({ name: 'terms_accepted', type: 'boolean', default: false })
34
+ termsAccepted: boolean = false
35
+
36
+ @Property({ name: 'password_hash', type: 'text', nullable: true })
37
+ passwordHash?: string | null
38
+
39
+ @Property({ name: 'expires_at', type: Date })
40
+ expiresAt!: Date
41
+
42
+ @Property({ name: 'completed_at', type: Date, nullable: true })
43
+ completedAt?: Date | null
44
+
45
+ @Property({ name: 'tenant_id', type: 'uuid', nullable: true })
46
+ tenantId?: string | null
47
+
48
+ @Property({ name: 'organization_id', type: 'uuid', nullable: true })
49
+ organizationId?: string | null
50
+
51
+ @Property({ name: 'user_id', type: 'uuid', nullable: true })
52
+ userId?: string | null
53
+
54
+ @Property({ name: 'last_email_sent_at', type: Date, nullable: true })
55
+ lastEmailSentAt?: Date | null
56
+
57
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
58
+ createdAt: Date = new Date()
59
+
60
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date(), nullable: true })
61
+ updatedAt?: Date
62
+
63
+ @Property({ name: 'deleted_at', type: Date, nullable: true })
64
+ deletedAt?: Date | null
65
+ }
66
+
67
+ export type { OnboardingStatus }