@nubase/create 0.1.4 → 0.1.5
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/dist/index.js +5 -3
- package/package.json +1 -1
- package/templates/backend/package.json +2 -2
- package/templates/backend/src/api/routes/auth.ts +16 -16
- package/templates/backend/src/api/routes/index.ts +1 -0
- package/templates/backend/src/api/routes/test-utils.ts +390 -0
- package/templates/backend/src/api/routes/ticket.ts +6 -6
- package/templates/backend/src/auth/index.ts +5 -5
- package/templates/backend/src/db/helpers/drizzle.ts +74 -28
- package/templates/backend/src/db/seed.ts +20 -16
- package/templates/backend/src/index.ts +6 -0
- package/templates/backend/src/middleware/workspace-middleware.ts +2 -2
- package/templates/frontend/.env.test.template +1 -0
- package/templates/frontend/e2e/auth.spec.ts +111 -0
- package/templates/frontend/e2e/fixtures/base.ts +146 -0
- package/templates/frontend/e2e/fixtures/test-api.ts +132 -0
- package/templates/frontend/e2e/global-setup.ts +74 -0
- package/templates/frontend/e2e/ticket.spec.ts +73 -0
- package/templates/frontend/e2e/utils/test-reporter.ts +73 -0
- package/templates/frontend/package.json +8 -1
- package/templates/frontend/playwright.config.ts +60 -0
- package/templates/frontend/src/styles/theme.css +1 -1
- package/templates/frontend/vite.config.ts +1 -1
- package/templates/root/package.json +8 -1
- package/templates/root/turbo.json +8 -1
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ function replaceInContent(content, options) {
|
|
|
26
26
|
const kebabName = toKebabCase(options.name);
|
|
27
27
|
const pascalName = toPascalCase(options.name);
|
|
28
28
|
const camelName = toCamelCase(options.name);
|
|
29
|
-
return content.replace(/__PROJECT_NAME__/g, kebabName).replace(/__PROJECT_NAME_PASCAL__/g, pascalName).replace(/__PROJECT_NAME_CAMEL__/g, camelName).replace(/__DB_NAME__/g, options.dbName).replace(/__DB_USER__/g, options.dbUser).replace(/__DB_PASSWORD__/g, options.dbPassword).replace(/__DEV_PORT__/g, String(options.devPort)).replace(/__TEST_PORT__/g, String(options.testPort)).replace(/__BACKEND_PORT__/g, String(options.backendPort)).replace(/__FRONTEND_PORT__/g, String(options.frontendPort));
|
|
29
|
+
return content.replace(/__PROJECT_NAME__/g, kebabName).replace(/__PROJECT_NAME_PASCAL__/g, pascalName).replace(/__PROJECT_NAME_CAMEL__/g, camelName).replace(/__DB_NAME__/g, options.dbName).replace(/__DB_USER__/g, options.dbUser).replace(/__DB_PASSWORD__/g, options.dbPassword).replace(/__DEV_PORT__/g, String(options.devPort)).replace(/__TEST_PORT__/g, String(options.testPort)).replace(/__BACKEND_PORT__/g, String(options.backendPort)).replace(/__FRONTEND_PORT__/g, String(options.frontendPort)).replace(/__TEST_BACKEND_PORT__/g, String(options.testBackendPort)).replace(/__TEST_FRONTEND_PORT__/g, String(options.testFrontendPort));
|
|
30
30
|
}
|
|
31
31
|
function copyTemplateDir(src, dest, options) {
|
|
32
32
|
fse.ensureDirSync(dest);
|
|
@@ -48,7 +48,7 @@ function copyTemplateDir(src, dest, options) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
async function main() {
|
|
51
|
-
program.name("@nubase/create").description("Create a new Nubase application").argument("[project-name]", "Name of the project").option("--db-name <name>", "Database name").option("--db-user <user>", "Database user").option("--db-password <password>", "Database password").option("--dev-port <port>", "Development database port", "5434").option("--test-port <port>", "Test database port", "5435").option("--backend-port <port>", "Backend server port", "3001").option("--frontend-port <port>", "Frontend dev server port", "3002").option("--skip-install", "Skip npm install").parse();
|
|
51
|
+
program.name("@nubase/create").description("Create a new Nubase application").argument("[project-name]", "Name of the project").option("--db-name <name>", "Database name").option("--db-user <user>", "Database user").option("--db-password <password>", "Database password").option("--dev-port <port>", "Development database port", "5434").option("--test-port <port>", "Test database port", "5435").option("--backend-port <port>", "Backend server port", "3001").option("--frontend-port <port>", "Frontend dev server port", "3002").option("--test-backend-port <port>", "Test backend server port", "4001").option("--test-frontend-port <port>", "Test frontend dev server port", "4002").option("--skip-install", "Skip npm install").parse();
|
|
52
52
|
const args = program.args;
|
|
53
53
|
const opts = program.opts();
|
|
54
54
|
console.log(chalk.bold.cyan("\n Welcome to Nubase!\n"));
|
|
@@ -78,7 +78,9 @@ async function main() {
|
|
|
78
78
|
devPort: Number.parseInt(opts.devPort, 10),
|
|
79
79
|
testPort: Number.parseInt(opts.testPort, 10),
|
|
80
80
|
backendPort: Number.parseInt(opts.backendPort, 10),
|
|
81
|
-
frontendPort: Number.parseInt(opts.frontendPort, 10)
|
|
81
|
+
frontendPort: Number.parseInt(opts.frontendPort, 10),
|
|
82
|
+
testBackendPort: Number.parseInt(opts.testBackendPort, 10),
|
|
83
|
+
testFrontendPort: Number.parseInt(opts.testFrontendPort, 10)
|
|
82
84
|
};
|
|
83
85
|
const targetDir = path.join(process.cwd(), projectName);
|
|
84
86
|
if (fs.existsSync(targetDir)) {
|
package/package.json
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "NODE_ENV=development tsx watch src/index.ts",
|
|
7
|
-
"dev:test": "PORT=
|
|
7
|
+
"dev:test": "PORT=__TEST_BACKEND_PORT__ DB_PORT=__TEST_PORT__ NODE_ENV=test tsx watch src/index.ts",
|
|
8
8
|
"build": "tsc",
|
|
9
9
|
"start": "node dist/index.js",
|
|
10
10
|
"db:dev:up": "docker compose -f docker/dev/docker-compose.yml up -d",
|
|
11
11
|
"db:dev:down": "docker compose -f docker/dev/docker-compose.yml down",
|
|
12
12
|
"db:dev:kill": "docker compose -f docker/dev/docker-compose.yml down -v && rm -rf docker/dev/postgresql-data",
|
|
13
13
|
"db:dev:seed": "NODE_ENV=development tsx src/db/seed.ts",
|
|
14
|
-
"db:dev:reset": "npm run db:dev:kill && npm run db:schema-sync && npm run db:dev:up && sleep
|
|
14
|
+
"db:dev:reset": "npm run db:dev:kill && npm run db:schema-sync && npm run db:dev:up && sleep 5 && npm run db:dev:seed",
|
|
15
15
|
"db:test:up": "docker compose -f docker/test/docker-compose.yml up -d",
|
|
16
16
|
"db:test:down": "docker compose -f docker/test/docker-compose.yml down",
|
|
17
17
|
"db:test:kill": "docker compose -f docker/test/docker-compose.yml down -v && rm -rf docker/test/postgresql-data",
|
|
@@ -8,7 +8,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
|
|
8
8
|
import { apiEndpoints } from "schema";
|
|
9
9
|
import jwt from "jsonwebtoken";
|
|
10
10
|
import type { __PROJECT_NAME_PASCAL__User } from "../../auth";
|
|
11
|
-
import {
|
|
11
|
+
import { getAdminDb } from "../../db/helpers/drizzle";
|
|
12
12
|
import { users, userWorkspaces, workspaces } from "../../db/schema";
|
|
13
13
|
|
|
14
14
|
// Short-lived secret for login tokens (in production, use a proper secret)
|
|
@@ -31,7 +31,7 @@ export const authHandlers = {
|
|
|
31
31
|
endpoint: apiEndpoints.loginStart,
|
|
32
32
|
handler: async ({ body }) => {
|
|
33
33
|
// Find user by username
|
|
34
|
-
const [user] = await
|
|
34
|
+
const [user] = await getAdminDb()
|
|
35
35
|
.select()
|
|
36
36
|
.from(users)
|
|
37
37
|
.where(eq(users.username, body.username));
|
|
@@ -50,7 +50,7 @@ export const authHandlers = {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// Get all workspaces this user belongs to
|
|
53
|
-
const userWorkspaceRows = await
|
|
53
|
+
const userWorkspaceRows = await getAdminDb()
|
|
54
54
|
.select()
|
|
55
55
|
.from(userWorkspaces)
|
|
56
56
|
.where(eq(userWorkspaces.userId, user.id));
|
|
@@ -61,7 +61,7 @@ export const authHandlers = {
|
|
|
61
61
|
|
|
62
62
|
// Fetch workspace details
|
|
63
63
|
const workspaceIds = userWorkspaceRows.map((uw) => uw.workspaceId);
|
|
64
|
-
const workspaceList = await
|
|
64
|
+
const workspaceList = await getAdminDb()
|
|
65
65
|
.select()
|
|
66
66
|
.from(workspaces)
|
|
67
67
|
.where(inArray(workspaces.id, workspaceIds));
|
|
@@ -109,7 +109,7 @@ export const authHandlers = {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
// Look up the selected workspace
|
|
112
|
-
const [workspace] = await
|
|
112
|
+
const [workspace] = await getAdminDb()
|
|
113
113
|
.select()
|
|
114
114
|
.from(workspaces)
|
|
115
115
|
.where(eq(workspaces.slug, body.workspace));
|
|
@@ -119,7 +119,7 @@ export const authHandlers = {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
// Verify user has access to this workspace
|
|
122
|
-
const [access] = await
|
|
122
|
+
const [access] = await getAdminDb()
|
|
123
123
|
.select()
|
|
124
124
|
.from(userWorkspaces)
|
|
125
125
|
.where(
|
|
@@ -134,7 +134,7 @@ export const authHandlers = {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
// Fetch the user
|
|
137
|
-
const [dbUser] = await
|
|
137
|
+
const [dbUser] = await getAdminDb()
|
|
138
138
|
.select()
|
|
139
139
|
.from(users)
|
|
140
140
|
.where(eq(users.id, decoded.userId));
|
|
@@ -180,7 +180,7 @@ export const authHandlers = {
|
|
|
180
180
|
const authController = getAuthController<__PROJECT_NAME_PASCAL__User>(ctx);
|
|
181
181
|
|
|
182
182
|
// Look up workspace
|
|
183
|
-
const [workspace] = await
|
|
183
|
+
const [workspace] = await getAdminDb()
|
|
184
184
|
.select()
|
|
185
185
|
.from(workspaces)
|
|
186
186
|
.where(eq(workspaces.slug, body.workspace));
|
|
@@ -190,7 +190,7 @@ export const authHandlers = {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
// Find user by username
|
|
193
|
-
const [dbUser] = await
|
|
193
|
+
const [dbUser] = await getAdminDb()
|
|
194
194
|
.select()
|
|
195
195
|
.from(users)
|
|
196
196
|
.where(eq(users.username, body.username));
|
|
@@ -209,7 +209,7 @@ export const authHandlers = {
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
// Verify user has access to this workspace
|
|
212
|
-
const [access] = await
|
|
212
|
+
const [access] = await getAdminDb()
|
|
213
213
|
.select()
|
|
214
214
|
.from(userWorkspaces)
|
|
215
215
|
.where(
|
|
@@ -293,7 +293,7 @@ export const authHandlers = {
|
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
// Check if workspace slug already exists
|
|
296
|
-
const [existingWorkspace] = await
|
|
296
|
+
const [existingWorkspace] = await getAdminDb()
|
|
297
297
|
.select()
|
|
298
298
|
.from(workspaces)
|
|
299
299
|
.where(eq(workspaces.slug, body.workspace));
|
|
@@ -303,7 +303,7 @@ export const authHandlers = {
|
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
// Check if username already exists
|
|
306
|
-
const [existingUser] = await
|
|
306
|
+
const [existingUser] = await getAdminDb()
|
|
307
307
|
.select()
|
|
308
308
|
.from(users)
|
|
309
309
|
.where(eq(users.username, body.username));
|
|
@@ -313,7 +313,7 @@ export const authHandlers = {
|
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
// Check if email already exists
|
|
316
|
-
const [existingEmail] = await
|
|
316
|
+
const [existingEmail] = await getAdminDb()
|
|
317
317
|
.select()
|
|
318
318
|
.from(users)
|
|
319
319
|
.where(eq(users.email, body.email));
|
|
@@ -328,7 +328,7 @@ export const authHandlers = {
|
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
// Create the workspace
|
|
331
|
-
const [newWorkspace] = await
|
|
331
|
+
const [newWorkspace] = await getAdminDb()
|
|
332
332
|
.insert(workspaces)
|
|
333
333
|
.values({
|
|
334
334
|
slug: body.workspace,
|
|
@@ -340,7 +340,7 @@ export const authHandlers = {
|
|
|
340
340
|
const passwordHash = await bcrypt.hash(body.password, 10);
|
|
341
341
|
|
|
342
342
|
// Create the admin user
|
|
343
|
-
const [newUser] = await
|
|
343
|
+
const [newUser] = await getAdminDb()
|
|
344
344
|
.insert(users)
|
|
345
345
|
.values({
|
|
346
346
|
email: body.email,
|
|
@@ -350,7 +350,7 @@ export const authHandlers = {
|
|
|
350
350
|
.returning();
|
|
351
351
|
|
|
352
352
|
// Link user to workspace
|
|
353
|
-
await
|
|
353
|
+
await getAdminDb().insert(userWorkspaces).values({
|
|
354
354
|
userId: newUser.id,
|
|
355
355
|
workspaceId: newWorkspace.id,
|
|
356
356
|
});
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { createHttpHandler } from "@nubase/backend";
|
|
2
|
+
import { emptySchema, nu } from "@nubase/core";
|
|
3
|
+
import bcrypt from "bcrypt";
|
|
4
|
+
import { eq, sql } from "drizzle-orm";
|
|
5
|
+
import { getAdminDb } from "../../db/helpers/drizzle";
|
|
6
|
+
import { tickets } from "../../db/schema/ticket";
|
|
7
|
+
import { users } from "../../db/schema/user";
|
|
8
|
+
import { userWorkspaces } from "../../db/schema/user-workspace";
|
|
9
|
+
import { workspaces } from "../../db/schema/workspace";
|
|
10
|
+
|
|
11
|
+
// Default test workspace slug
|
|
12
|
+
const DEFAULT_TEST_WORKSPACE = "tavern";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Helper to get workspace by slug using admin DB (bypasses RLS).
|
|
16
|
+
* Used by test utilities that don't have auth context.
|
|
17
|
+
*/
|
|
18
|
+
async function getWorkspaceBySlug(slug: string) {
|
|
19
|
+
const db = getAdminDb();
|
|
20
|
+
const workspaceRows = await db
|
|
21
|
+
.select()
|
|
22
|
+
.from(workspaces)
|
|
23
|
+
.where(eq(workspaces.slug, slug));
|
|
24
|
+
|
|
25
|
+
if (workspaceRows.length === 0) {
|
|
26
|
+
throw new Error(`Workspace not found: ${slug}`);
|
|
27
|
+
}
|
|
28
|
+
return workspaceRows[0];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Clear all data from the database - used between tests
|
|
32
|
+
export const handleClearDatabase = createHttpHandler({
|
|
33
|
+
endpoint: {
|
|
34
|
+
method: "POST" as const,
|
|
35
|
+
path: "/api/test/clear-database",
|
|
36
|
+
requestParams: emptySchema,
|
|
37
|
+
requestBody: nu.object({
|
|
38
|
+
workspace: nu.string().optional(),
|
|
39
|
+
}),
|
|
40
|
+
responseBody: nu.object({
|
|
41
|
+
success: nu.boolean(),
|
|
42
|
+
message: nu.string(),
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
handler: async ({ body }) => {
|
|
46
|
+
// Only allow in test environment
|
|
47
|
+
if (process.env.NODE_ENV !== "test" && process.env.DB_PORT !== "__TEST_PORT__") {
|
|
48
|
+
throw new Error("Database cleanup is only allowed in test environment");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const workspaceSlug = body?.workspace || DEFAULT_TEST_WORKSPACE;
|
|
52
|
+
const workspace = await getWorkspaceBySlug(workspaceSlug);
|
|
53
|
+
const db = getAdminDb();
|
|
54
|
+
|
|
55
|
+
// Clear ALL tickets (not just for this workspace) to avoid sequence conflicts
|
|
56
|
+
await db.delete(tickets);
|
|
57
|
+
|
|
58
|
+
// Reset the ID sequence to start from 1
|
|
59
|
+
await db.execute(sql`ALTER SEQUENCE tickets_id_seq RESTART WITH 1`);
|
|
60
|
+
|
|
61
|
+
// Clear all user_workspaces associations (for all workspaces in test env)
|
|
62
|
+
await db.delete(userWorkspaces);
|
|
63
|
+
|
|
64
|
+
// Clear all users (in test env we start fresh each time)
|
|
65
|
+
await db.delete(users);
|
|
66
|
+
|
|
67
|
+
// Reset the users ID sequence
|
|
68
|
+
await db.execute(sql`ALTER SEQUENCE users_id_seq RESTART WITH 1`);
|
|
69
|
+
|
|
70
|
+
// Seed a default test user for this workspace
|
|
71
|
+
const passwordHash = await bcrypt.hash("password123", 12);
|
|
72
|
+
|
|
73
|
+
// Create the test user
|
|
74
|
+
const [newUser] = await db
|
|
75
|
+
.insert(users)
|
|
76
|
+
.values({
|
|
77
|
+
email: "admin@example.com",
|
|
78
|
+
username: "admin",
|
|
79
|
+
passwordHash,
|
|
80
|
+
})
|
|
81
|
+
.returning();
|
|
82
|
+
|
|
83
|
+
// Link user to workspace
|
|
84
|
+
await db.insert(userWorkspaces).values({
|
|
85
|
+
userId: newUser.id,
|
|
86
|
+
workspaceId: workspace.id,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
success: true,
|
|
91
|
+
message: `Database cleared and test user seeded for workspace ${workspace.slug}`,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Seed test data - useful for specific test scenarios
|
|
97
|
+
export const handleSeedTestData = createHttpHandler({
|
|
98
|
+
endpoint: {
|
|
99
|
+
method: "POST" as const,
|
|
100
|
+
path: "/api/test/seed",
|
|
101
|
+
requestParams: emptySchema,
|
|
102
|
+
requestBody: nu.object({
|
|
103
|
+
workspace: nu.string().optional(),
|
|
104
|
+
tickets: nu
|
|
105
|
+
.array(
|
|
106
|
+
nu.object({
|
|
107
|
+
title: nu.string(),
|
|
108
|
+
description: nu.string().optional(),
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
.optional(),
|
|
112
|
+
}),
|
|
113
|
+
responseBody: nu.object({
|
|
114
|
+
success: nu.boolean(),
|
|
115
|
+
message: nu.string(),
|
|
116
|
+
data: nu
|
|
117
|
+
.object({
|
|
118
|
+
ticketIds: nu.array(nu.number()),
|
|
119
|
+
})
|
|
120
|
+
.optional(),
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
handler: async ({ body }) => {
|
|
124
|
+
// Only allow in test environment
|
|
125
|
+
if (process.env.NODE_ENV !== "test" && process.env.DB_PORT !== "__TEST_PORT__") {
|
|
126
|
+
throw new Error("Test seeding is only allowed in test environment");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const workspaceSlug = body?.workspace || DEFAULT_TEST_WORKSPACE;
|
|
130
|
+
const workspace = await getWorkspaceBySlug(workspaceSlug);
|
|
131
|
+
const db = getAdminDb();
|
|
132
|
+
const insertedTicketIds: number[] = [];
|
|
133
|
+
|
|
134
|
+
if (body?.tickets) {
|
|
135
|
+
for (const ticket of body.tickets) {
|
|
136
|
+
const result = await db
|
|
137
|
+
.insert(tickets)
|
|
138
|
+
.values({
|
|
139
|
+
workspaceId: workspace.id,
|
|
140
|
+
title: ticket.title,
|
|
141
|
+
description: ticket.description,
|
|
142
|
+
})
|
|
143
|
+
.returning();
|
|
144
|
+
|
|
145
|
+
if (result[0]) {
|
|
146
|
+
insertedTicketIds.push(result[0].id);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
success: true,
|
|
153
|
+
message: `Test data seeded for workspace ${workspace.slug}`,
|
|
154
|
+
data: {
|
|
155
|
+
ticketIds: insertedTicketIds,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Get database statistics - useful for verifying test state
|
|
162
|
+
export const handleGetDatabaseStats = createHttpHandler({
|
|
163
|
+
endpoint: {
|
|
164
|
+
method: "GET" as const,
|
|
165
|
+
path: "/api/test/stats",
|
|
166
|
+
requestParams: nu.object({
|
|
167
|
+
workspace: nu.string().optional(),
|
|
168
|
+
}),
|
|
169
|
+
requestBody: emptySchema,
|
|
170
|
+
responseBody: nu.object({
|
|
171
|
+
tickets: nu.object({
|
|
172
|
+
count: nu.number(),
|
|
173
|
+
}),
|
|
174
|
+
}),
|
|
175
|
+
},
|
|
176
|
+
handler: async ({ params }) => {
|
|
177
|
+
// Only allow in test environment
|
|
178
|
+
if (process.env.NODE_ENV !== "test" && process.env.DB_PORT !== "__TEST_PORT__") {
|
|
179
|
+
throw new Error("Database stats are only available in test environment");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const workspaceSlug = params?.workspace || DEFAULT_TEST_WORKSPACE;
|
|
183
|
+
const workspace = await getWorkspaceBySlug(workspaceSlug);
|
|
184
|
+
const db = getAdminDb();
|
|
185
|
+
const ticketRows = await db
|
|
186
|
+
.select()
|
|
187
|
+
.from(tickets)
|
|
188
|
+
.where(eq(tickets.workspaceId, workspace.id));
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
tickets: {
|
|
192
|
+
count: ticketRows.length,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Ensure default workspace exists - called at the start of test runs
|
|
199
|
+
export const handleEnsureWorkspace = createHttpHandler({
|
|
200
|
+
endpoint: {
|
|
201
|
+
method: "POST" as const,
|
|
202
|
+
path: "/api/test/ensure-workspace",
|
|
203
|
+
requestParams: emptySchema,
|
|
204
|
+
requestBody: nu.object({
|
|
205
|
+
workspace: nu.string().optional(),
|
|
206
|
+
}),
|
|
207
|
+
responseBody: nu.object({
|
|
208
|
+
success: nu.boolean(),
|
|
209
|
+
workspace: nu.object({
|
|
210
|
+
id: nu.number(),
|
|
211
|
+
slug: nu.string(),
|
|
212
|
+
name: nu.string(),
|
|
213
|
+
}),
|
|
214
|
+
}),
|
|
215
|
+
},
|
|
216
|
+
handler: async ({ body }) => {
|
|
217
|
+
// Only allow in test environment
|
|
218
|
+
if (process.env.NODE_ENV !== "test" && process.env.DB_PORT !== "__TEST_PORT__") {
|
|
219
|
+
throw new Error(
|
|
220
|
+
"Workspace management is only allowed in test environment",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const workspaceSlug = body?.workspace || DEFAULT_TEST_WORKSPACE;
|
|
225
|
+
const db = getAdminDb();
|
|
226
|
+
|
|
227
|
+
// Check if workspace exists
|
|
228
|
+
const existingWorkspaces = await db
|
|
229
|
+
.select()
|
|
230
|
+
.from(workspaces)
|
|
231
|
+
.where(eq(workspaces.slug, workspaceSlug));
|
|
232
|
+
|
|
233
|
+
if (existingWorkspaces.length > 0) {
|
|
234
|
+
return {
|
|
235
|
+
success: true,
|
|
236
|
+
workspace: {
|
|
237
|
+
id: existingWorkspaces[0].id,
|
|
238
|
+
slug: existingWorkspaces[0].slug,
|
|
239
|
+
name: existingWorkspaces[0].name,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Create the workspace
|
|
245
|
+
const result = await db
|
|
246
|
+
.insert(workspaces)
|
|
247
|
+
.values({
|
|
248
|
+
slug: workspaceSlug,
|
|
249
|
+
name: workspaceSlug.charAt(0).toUpperCase() + workspaceSlug.slice(1),
|
|
250
|
+
})
|
|
251
|
+
.returning();
|
|
252
|
+
|
|
253
|
+
if (result.length === 0) {
|
|
254
|
+
throw new Error("Failed to create workspace");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
success: true,
|
|
259
|
+
workspace: {
|
|
260
|
+
id: result[0].id,
|
|
261
|
+
slug: result[0].slug,
|
|
262
|
+
name: result[0].name,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Seed a user with multiple workspaces - for testing workspace selection flow
|
|
270
|
+
*/
|
|
271
|
+
export const handleSeedMultiWorkspaceUser = createHttpHandler({
|
|
272
|
+
endpoint: {
|
|
273
|
+
method: "POST" as const,
|
|
274
|
+
path: "/api/test/seed-multi-workspace-user",
|
|
275
|
+
requestParams: emptySchema,
|
|
276
|
+
requestBody: nu.object({
|
|
277
|
+
username: nu.string(),
|
|
278
|
+
password: nu.string(),
|
|
279
|
+
email: nu.string(),
|
|
280
|
+
workspaces: nu.array(
|
|
281
|
+
nu.object({
|
|
282
|
+
slug: nu.string(),
|
|
283
|
+
name: nu.string(),
|
|
284
|
+
}),
|
|
285
|
+
),
|
|
286
|
+
}),
|
|
287
|
+
responseBody: nu.object({
|
|
288
|
+
success: nu.boolean(),
|
|
289
|
+
message: nu.string(),
|
|
290
|
+
workspaces: nu.array(
|
|
291
|
+
nu.object({
|
|
292
|
+
id: nu.number(),
|
|
293
|
+
slug: nu.string(),
|
|
294
|
+
name: nu.string(),
|
|
295
|
+
}),
|
|
296
|
+
),
|
|
297
|
+
}),
|
|
298
|
+
},
|
|
299
|
+
handler: async ({ body }) => {
|
|
300
|
+
// Only allow in test environment
|
|
301
|
+
if (process.env.NODE_ENV !== "test" && process.env.DB_PORT !== "__TEST_PORT__") {
|
|
302
|
+
throw new Error(
|
|
303
|
+
"Multi-workspace user seeding is only allowed in test environment",
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const db = getAdminDb();
|
|
308
|
+
const createdWorkspaces: { id: number; slug: string; name: string }[] = [];
|
|
309
|
+
const passwordHash = await bcrypt.hash(body.password, 12);
|
|
310
|
+
|
|
311
|
+
// Check if user already exists
|
|
312
|
+
let userId: number;
|
|
313
|
+
const existingUsers = await db
|
|
314
|
+
.select()
|
|
315
|
+
.from(users)
|
|
316
|
+
.where(eq(users.username, body.username));
|
|
317
|
+
|
|
318
|
+
if (existingUsers.length > 0) {
|
|
319
|
+
userId = existingUsers[0].id;
|
|
320
|
+
} else {
|
|
321
|
+
// Create user (root-level, no workspace)
|
|
322
|
+
const [newUser] = await db
|
|
323
|
+
.insert(users)
|
|
324
|
+
.values({
|
|
325
|
+
email: body.email,
|
|
326
|
+
username: body.username,
|
|
327
|
+
passwordHash,
|
|
328
|
+
})
|
|
329
|
+
.returning();
|
|
330
|
+
userId = newUser.id;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const workspaceData of body.workspaces) {
|
|
334
|
+
// Check if workspace exists
|
|
335
|
+
let workspace = await db
|
|
336
|
+
.select()
|
|
337
|
+
.from(workspaces)
|
|
338
|
+
.where(eq(workspaces.slug, workspaceData.slug))
|
|
339
|
+
.then((rows) => rows[0]);
|
|
340
|
+
|
|
341
|
+
// Create workspace if it doesn't exist
|
|
342
|
+
if (!workspace) {
|
|
343
|
+
const result = await db
|
|
344
|
+
.insert(workspaces)
|
|
345
|
+
.values({
|
|
346
|
+
slug: workspaceData.slug,
|
|
347
|
+
name: workspaceData.name,
|
|
348
|
+
})
|
|
349
|
+
.returning();
|
|
350
|
+
workspace = result[0];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Check if user already has access to this workspace
|
|
354
|
+
const existingAccess = await db
|
|
355
|
+
.select()
|
|
356
|
+
.from(userWorkspaces)
|
|
357
|
+
.where(eq(userWorkspaces.userId, userId))
|
|
358
|
+
.then((rows) => rows.find((uw) => uw.workspaceId === workspace.id));
|
|
359
|
+
|
|
360
|
+
// Link user to workspace if not already linked
|
|
361
|
+
if (!existingAccess) {
|
|
362
|
+
await db.insert(userWorkspaces).values({
|
|
363
|
+
userId,
|
|
364
|
+
workspaceId: workspace.id,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
createdWorkspaces.push({
|
|
369
|
+
id: workspace.id,
|
|
370
|
+
slug: workspace.slug,
|
|
371
|
+
name: workspace.name,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
success: true,
|
|
377
|
+
message: `User ${body.username} seeded in ${createdWorkspaces.length} workspaces`,
|
|
378
|
+
workspaces: createdWorkspaces,
|
|
379
|
+
};
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Export test utils handlers object
|
|
384
|
+
export const testUtilsHandlers = {
|
|
385
|
+
clearDatabase: handleClearDatabase,
|
|
386
|
+
ensureWorkspace: handleEnsureWorkspace,
|
|
387
|
+
seedTestData: handleSeedTestData,
|
|
388
|
+
getDatabaseStats: handleGetDatabaseStats,
|
|
389
|
+
seedMultiWorkspaceUser: handleSeedMultiWorkspaceUser,
|
|
390
|
+
};
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { createHttpHandler, HttpError } from "@nubase/backend";
|
|
2
2
|
import { eq } from "drizzle-orm";
|
|
3
3
|
import { apiEndpoints } from "schema";
|
|
4
|
-
import {
|
|
4
|
+
import { getDb } from "../../db/helpers/drizzle";
|
|
5
5
|
import { tickets } from "../../db/schema";
|
|
6
6
|
|
|
7
7
|
export const ticketHandlers = {
|
|
8
8
|
getTickets: createHttpHandler({
|
|
9
9
|
endpoint: apiEndpoints.getTickets,
|
|
10
10
|
handler: async () => {
|
|
11
|
-
const allTickets = await
|
|
11
|
+
const allTickets = await getDb().select().from(tickets);
|
|
12
12
|
return allTickets.map((ticket) => ({
|
|
13
13
|
id: ticket.id,
|
|
14
14
|
title: ticket.title,
|
|
@@ -20,7 +20,7 @@ export const ticketHandlers = {
|
|
|
20
20
|
getTicket: createHttpHandler({
|
|
21
21
|
endpoint: apiEndpoints.getTicket,
|
|
22
22
|
handler: async ({ params }) => {
|
|
23
|
-
const [ticket] = await
|
|
23
|
+
const [ticket] = await getDb()
|
|
24
24
|
.select()
|
|
25
25
|
.from(tickets)
|
|
26
26
|
.where(eq(tickets.id, params.id));
|
|
@@ -40,7 +40,7 @@ export const ticketHandlers = {
|
|
|
40
40
|
postTicket: createHttpHandler({
|
|
41
41
|
endpoint: apiEndpoints.postTicket,
|
|
42
42
|
handler: async ({ body }) => {
|
|
43
|
-
const [ticket] = await
|
|
43
|
+
const [ticket] = await getDb()
|
|
44
44
|
.insert(tickets)
|
|
45
45
|
.values({
|
|
46
46
|
workspaceId: 1, // TODO: Get from context
|
|
@@ -75,7 +75,7 @@ export const ticketHandlers = {
|
|
|
75
75
|
updateData.description = body.description;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
const [ticket] = await
|
|
78
|
+
const [ticket] = await getDb()
|
|
79
79
|
.update(tickets)
|
|
80
80
|
.set(updateData)
|
|
81
81
|
.where(eq(tickets.id, params.id))
|
|
@@ -96,7 +96,7 @@ export const ticketHandlers = {
|
|
|
96
96
|
deleteTicket: createHttpHandler({
|
|
97
97
|
endpoint: apiEndpoints.deleteTicket,
|
|
98
98
|
handler: async ({ params }) => {
|
|
99
|
-
const [deleted] = await
|
|
99
|
+
const [deleted] = await getDb()
|
|
100
100
|
.delete(tickets)
|
|
101
101
|
.where(eq(tickets.id, params.id))
|
|
102
102
|
.returning();
|
|
@@ -9,7 +9,7 @@ import bcrypt from "bcrypt";
|
|
|
9
9
|
import { and, eq } from "drizzle-orm";
|
|
10
10
|
import type { Context } from "hono";
|
|
11
11
|
import jwt from "jsonwebtoken";
|
|
12
|
-
import {
|
|
12
|
+
import { getAdminDb } from "../db/helpers/drizzle";
|
|
13
13
|
import { users, userWorkspaces } from "../db/schema";
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -77,7 +77,7 @@ export class __PROJECT_NAME_PASCAL__AuthController
|
|
|
77
77
|
) as __PROJECT_NAME_PASCAL__TokenPayload;
|
|
78
78
|
|
|
79
79
|
// Fetch user from database to ensure they still exist
|
|
80
|
-
const [dbUser] = await
|
|
80
|
+
const [dbUser] = await getAdminDb()
|
|
81
81
|
.select()
|
|
82
82
|
.from(users)
|
|
83
83
|
.where(eq(users.id, decoded.userId));
|
|
@@ -87,7 +87,7 @@ export class __PROJECT_NAME_PASCAL__AuthController
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
// Verify user still has access to the workspace in the token
|
|
90
|
-
const [access] = await
|
|
90
|
+
const [access] = await getAdminDb()
|
|
91
91
|
.select()
|
|
92
92
|
.from(userWorkspaces)
|
|
93
93
|
.where(
|
|
@@ -180,7 +180,7 @@ export class __PROJECT_NAME_PASCAL__AuthController
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
// Find user by username
|
|
183
|
-
const [dbUser] = await
|
|
183
|
+
const [dbUser] = await getAdminDb()
|
|
184
184
|
.select()
|
|
185
185
|
.from(users)
|
|
186
186
|
.where(eq(users.username, username));
|
|
@@ -196,7 +196,7 @@ export class __PROJECT_NAME_PASCAL__AuthController
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
// Check if user has access to the workspace
|
|
199
|
-
const [access] = await
|
|
199
|
+
const [access] = await getAdminDb()
|
|
200
200
|
.select()
|
|
201
201
|
.from(userWorkspaces)
|
|
202
202
|
.where(
|