@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.
- package/build.mjs +62 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/onboarding/acl.js +11 -0
- package/dist/modules/onboarding/acl.js.map +7 -0
- package/dist/modules/onboarding/api/get/onboarding/verify.js +208 -0
- package/dist/modules/onboarding/api/get/onboarding/verify.js.map +7 -0
- package/dist/modules/onboarding/api/post/onboarding.js +193 -0
- package/dist/modules/onboarding/api/post/onboarding.js.map +7 -0
- package/dist/modules/onboarding/data/entities.js +84 -0
- package/dist/modules/onboarding/data/entities.js.map +7 -0
- package/dist/modules/onboarding/data/validators.js +27 -0
- package/dist/modules/onboarding/data/validators.js.map +7 -0
- package/dist/modules/onboarding/emails/AdminNotificationEmail.js +18 -0
- package/dist/modules/onboarding/emails/AdminNotificationEmail.js.map +7 -0
- package/dist/modules/onboarding/emails/VerificationEmail.js +36 -0
- package/dist/modules/onboarding/emails/VerificationEmail.js.map +7 -0
- package/dist/modules/onboarding/frontend/onboarding/page.js +279 -0
- package/dist/modules/onboarding/frontend/onboarding/page.js.map +7 -0
- package/dist/modules/onboarding/index.js +14 -0
- package/dist/modules/onboarding/index.js.map +7 -0
- package/dist/modules/onboarding/lib/service.js +83 -0
- package/dist/modules/onboarding/lib/service.js.map +7 -0
- package/dist/modules/onboarding/migrations/Migration20260112142945.js +12 -0
- package/dist/modules/onboarding/migrations/Migration20260112142945.js.map +7 -0
- package/generated/entities/onboarding_request/index.ts +19 -0
- package/generated/entities.ids.generated.ts +11 -0
- package/generated/entity-fields-registry.ts +11 -0
- package/jest.config.cjs +19 -0
- package/package.json +83 -0
- package/src/index.ts +2 -0
- package/src/modules/onboarding/acl.ts +7 -0
- package/src/modules/onboarding/api/get/onboarding/verify.ts +224 -0
- package/src/modules/onboarding/api/post/onboarding.ts +210 -0
- package/src/modules/onboarding/data/entities.ts +67 -0
- package/src/modules/onboarding/data/validators.ts +27 -0
- package/src/modules/onboarding/emails/AdminNotificationEmail.tsx +32 -0
- package/src/modules/onboarding/emails/VerificationEmail.tsx +54 -0
- package/src/modules/onboarding/frontend/onboarding/page.tsx +305 -0
- package/src/modules/onboarding/i18n/de.json +49 -0
- package/src/modules/onboarding/i18n/en.json +49 -0
- package/src/modules/onboarding/i18n/es.json +49 -0
- package/src/modules/onboarding/i18n/pl.json +49 -0
- package/src/modules/onboarding/index.ts +12 -0
- package/src/modules/onboarding/lib/service.ts +90 -0
- package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +230 -0
- package/src/modules/onboarding/migrations/Migration20260112142945.ts +11 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +9 -0
- 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
|
+
}
|
package/jest.config.cjs
ADDED
|
@@ -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,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 }
|