@nubase/create 0.1.20 → 0.1.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nubase/create",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Create a new Nubase application",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,6 @@
1
1
  import { createHttpHandler, HttpError } from "@nubase/backend";
2
- import { eq } from "drizzle-orm";
2
+ import type { SQL } from "drizzle-orm";
3
+ import { and, eq, ilike, inArray } from "drizzle-orm";
3
4
  import { apiEndpoints } from "schema";
4
5
  import { getDb } from "../../db/helpers/drizzle";
5
6
  import { tickets } from "../../db/schema";
@@ -7,12 +8,46 @@ import { tickets } from "../../db/schema";
7
8
  export const ticketHandlers = {
8
9
  getTickets: createHttpHandler({
9
10
  endpoint: apiEndpoints.getTickets,
10
- handler: async () => {
11
- const allTickets = await getDb().select().from(tickets);
11
+ handler: async ({ params }) => {
12
+ const db = getDb();
13
+
14
+ // Build filter conditions
15
+ const conditions: SQL[] = [];
16
+
17
+ // Filter by title (case-insensitive partial match)
18
+ if (params.title) {
19
+ conditions.push(ilike(tickets.title, `%${params.title}%`));
20
+ }
21
+
22
+ // Filter by description (case-insensitive partial match)
23
+ if (params.description) {
24
+ conditions.push(ilike(tickets.description, `%${params.description}%`));
25
+ }
26
+
27
+ // Filter by assigneeId (supports single value or array for multi-select)
28
+ if (params.assigneeId !== undefined) {
29
+ if (Array.isArray(params.assigneeId)) {
30
+ if (params.assigneeId.length > 0) {
31
+ conditions.push(inArray(tickets.assigneeId, params.assigneeId));
32
+ }
33
+ } else {
34
+ conditions.push(eq(tickets.assigneeId, params.assigneeId));
35
+ }
36
+ }
37
+
38
+ const allTickets =
39
+ conditions.length > 0
40
+ ? await db
41
+ .select()
42
+ .from(tickets)
43
+ .where(and(...conditions))
44
+ : await db.select().from(tickets);
45
+
12
46
  return allTickets.map((ticket) => ({
13
47
  id: ticket.id,
14
48
  title: ticket.title,
15
49
  description: ticket.description ?? undefined,
50
+ assigneeId: ticket.assigneeId ?? undefined,
16
51
  }));
17
52
  },
18
53
  }),
@@ -33,6 +68,7 @@ export const ticketHandlers = {
33
68
  id: ticket.id,
34
69
  title: ticket.title,
35
70
  description: ticket.description ?? undefined,
71
+ assigneeId: ticket.assigneeId ?? undefined,
36
72
  };
37
73
  },
38
74
  }),
@@ -46,6 +82,7 @@ export const ticketHandlers = {
46
82
  workspaceId: 1, // TODO: Get from context
47
83
  title: body.title,
48
84
  description: body.description,
85
+ assigneeId: body.assigneeId,
49
86
  })
50
87
  .returning();
51
88
 
@@ -57,6 +94,7 @@ export const ticketHandlers = {
57
94
  id: ticket.id,
58
95
  title: ticket.title,
59
96
  description: ticket.description ?? undefined,
97
+ assigneeId: ticket.assigneeId ?? undefined,
60
98
  };
61
99
  },
62
100
  }),
@@ -64,7 +102,12 @@ export const ticketHandlers = {
64
102
  patchTicket: createHttpHandler({
65
103
  endpoint: apiEndpoints.patchTicket,
66
104
  handler: async ({ params, body }) => {
67
- const updateData: { title?: string; description?: string; updatedAt: Date } = {
105
+ const updateData: {
106
+ title?: string;
107
+ description?: string;
108
+ assigneeId?: number | null;
109
+ updatedAt: Date;
110
+ } = {
68
111
  updatedAt: new Date(),
69
112
  };
70
113
 
@@ -74,6 +117,9 @@ export const ticketHandlers = {
74
117
  if (body.description !== undefined) {
75
118
  updateData.description = body.description;
76
119
  }
120
+ if (body.assigneeId !== undefined) {
121
+ updateData.assigneeId = body.assigneeId;
122
+ }
77
123
 
78
124
  const [ticket] = await getDb()
79
125
  .update(tickets)
@@ -89,6 +135,7 @@ export const ticketHandlers = {
89
135
  id: ticket.id,
90
136
  title: ticket.title,
91
137
  description: ticket.description ?? undefined,
138
+ assigneeId: ticket.assigneeId ?? undefined,
92
139
  };
93
140
  },
94
141
  }),
@@ -1,10 +1,147 @@
1
- import { createHttpHandler } from "@nubase/backend";
2
- import { ilike } from "drizzle-orm";
1
+ import { createHttpHandler, HttpError } from "@nubase/backend";
2
+ import type { SQL } from "drizzle-orm";
3
+ import { and, eq, ilike } from "drizzle-orm";
3
4
  import { apiEndpoints } from "schema";
4
5
  import { getDb } from "../../db/helpers/drizzle";
5
6
  import { users } from "../../db/schema";
6
7
 
7
8
  export const userHandlers = {
9
+ /** Get all users with optional filters. */
10
+ getUsers: createHttpHandler({
11
+ endpoint: apiEndpoints.getUsers,
12
+ handler: async ({ params }) => {
13
+ const db = getDb();
14
+
15
+ // Build filter conditions
16
+ const conditions: SQL[] = [];
17
+
18
+ // Filter by displayName (case-insensitive partial match)
19
+ if (params.displayName) {
20
+ conditions.push(ilike(users.displayName, `%${params.displayName}%`));
21
+ }
22
+
23
+ // Filter by email (case-insensitive partial match)
24
+ if (params.email) {
25
+ conditions.push(ilike(users.email, `%${params.email}%`));
26
+ }
27
+
28
+ const query =
29
+ conditions.length > 0
30
+ ? db.select().from(users).where(and(...conditions))
31
+ : db.select().from(users);
32
+
33
+ const results = await query;
34
+
35
+ return results.map((u) => ({
36
+ id: u.id,
37
+ email: u.email,
38
+ displayName: u.displayName,
39
+ }));
40
+ },
41
+ }),
42
+
43
+ /** Get a single user by ID. */
44
+ getUser: createHttpHandler({
45
+ endpoint: apiEndpoints.getUser,
46
+ handler: async ({ params }) => {
47
+ const db = getDb();
48
+ const [user] = await db.select().from(users).where(eq(users.id, params.id));
49
+
50
+ if (!user) {
51
+ throw new HttpError(404, "User not found");
52
+ }
53
+
54
+ return {
55
+ id: user.id,
56
+ email: user.email,
57
+ displayName: user.displayName,
58
+ };
59
+ },
60
+ }),
61
+
62
+ /** Create a new user. */
63
+ postUser: createHttpHandler({
64
+ endpoint: apiEndpoints.postUser,
65
+ handler: async ({ body }) => {
66
+ const db = getDb();
67
+
68
+ // Check if user with this email already exists
69
+ const [existing] = await db.select().from(users).where(eq(users.email, body.email));
70
+
71
+ if (existing) {
72
+ throw new HttpError(409, "User with this email already exists");
73
+ }
74
+
75
+ const [user] = await db
76
+ .insert(users)
77
+ .values({
78
+ email: body.email,
79
+ displayName: body.displayName,
80
+ passwordHash: "placeholder-requires-password-reset",
81
+ })
82
+ .returning();
83
+
84
+ if (!user) {
85
+ throw new HttpError(500, "Failed to create user");
86
+ }
87
+
88
+ return {
89
+ id: user.id,
90
+ email: user.email,
91
+ displayName: user.displayName,
92
+ };
93
+ },
94
+ }),
95
+
96
+ /** Update a user by ID. */
97
+ patchUser: createHttpHandler({
98
+ endpoint: apiEndpoints.patchUser,
99
+ handler: async ({ params, body }) => {
100
+ const db = getDb();
101
+
102
+ const updateData: { email?: string; displayName?: string } = {};
103
+ if (body.email !== undefined) {
104
+ updateData.email = body.email;
105
+ }
106
+ if (body.displayName !== undefined) {
107
+ updateData.displayName = body.displayName;
108
+ }
109
+
110
+ const [user] = await db
111
+ .update(users)
112
+ .set(updateData)
113
+ .where(eq(users.id, params.id))
114
+ .returning();
115
+
116
+ if (!user) {
117
+ throw new HttpError(404, "User not found");
118
+ }
119
+
120
+ return {
121
+ id: user.id,
122
+ email: user.email,
123
+ displayName: user.displayName,
124
+ };
125
+ },
126
+ }),
127
+
128
+ /** Delete a user by ID. */
129
+ deleteUser: createHttpHandler({
130
+ endpoint: apiEndpoints.deleteUser,
131
+ handler: async ({ params }) => {
132
+ const db = getDb();
133
+
134
+ const [deleted] = await db.delete(users).where(eq(users.id, params.id)).returning();
135
+
136
+ if (!deleted) {
137
+ throw new HttpError(404, "User not found");
138
+ }
139
+
140
+ return { success: true };
141
+ },
142
+ }),
143
+
144
+ /** Lookup users for select/autocomplete fields. */
8
145
  lookupUsers: createHttpHandler({
9
146
  endpoint: apiEndpoints.lookupUsers,
10
147
  handler: async ({ params }) => {
@@ -1,4 +1,5 @@
1
1
  import { integer, pgTable, serial, text, timestamp, varchar } from "drizzle-orm/pg-core";
2
+ import { users } from "./user";
2
3
  import { workspaces } from "./workspace";
3
4
 
4
5
  export const tickets = pgTable("tickets", {
@@ -8,6 +9,7 @@ export const tickets = pgTable("tickets", {
8
9
  .references(() => workspaces.id, { onDelete: "cascade" }),
9
10
  title: varchar("title", { length: 255 }).notNull(),
10
11
  description: text("description"),
12
+ assigneeId: integer("assignee_id").references(() => users.id, { onDelete: "set null" }),
11
13
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
12
14
  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
13
15
  });
@@ -1,6 +1,6 @@
1
1
  import type { NubaseFrontendConfig } from "@nubase/frontend";
2
2
  import { defaultKeybindings, resourceLink } from "@nubase/frontend";
3
- import { Home, TicketIcon } from "lucide-react";
3
+ import { Home, TicketIcon, UsersIcon } from "lucide-react";
4
4
  import { apiEndpoints } from "schema";
5
5
  import { __PROJECT_NAME_PASCAL__AuthController } from "./auth/__PROJECT_NAME_PASCAL__AuthController";
6
6
  import { analyticsDashboard } from "./dashboards/analytics";
@@ -26,6 +26,12 @@ export const config: NubaseFrontendConfig<typeof apiEndpoints> = {
26
26
  label: "Tickets",
27
27
  href: resourceLink(ticketResource, "search"),
28
28
  },
29
+ {
30
+ id: "users",
31
+ icon: UsersIcon,
32
+ label: "Users",
33
+ href: resourceLink(userResource, "search"),
34
+ },
29
35
  ],
30
36
  resources: {
31
37
  [ticketResource.id]: ticketResource,
@@ -1,8 +1,60 @@
1
- import { createResource } from "@nubase/frontend";
1
+ import { createResource, showToast } from "@nubase/frontend";
2
+ import { TrashIcon } from "lucide-react";
2
3
  import { apiEndpoints } from "schema";
3
4
 
4
5
  export const ticketResource = createResource("ticket")
5
6
  .withApiEndpoints(apiEndpoints)
7
+ .withActions({
8
+ delete: {
9
+ label: "Delete",
10
+ icon: TrashIcon,
11
+ variant: "destructive" as const,
12
+ onExecute: async ({ selectedIds, context }) => {
13
+ if (!selectedIds || selectedIds.length === 0) {
14
+ showToast("No tickets selected for deletion", "error");
15
+ return;
16
+ }
17
+
18
+ const ticketCount = selectedIds.length;
19
+ const ticketLabel = ticketCount === 1 ? "ticket" : "tickets";
20
+
21
+ // Show confirmation dialog
22
+ const confirmed = await new Promise<boolean>((resolve) => {
23
+ context.dialog.openDialog({
24
+ title: "Delete Tickets",
25
+ content: `Are you sure you want to delete ${ticketCount} ${ticketLabel}? This action cannot be undone.`,
26
+ confirmText: "Delete",
27
+ confirmVariant: "destructive",
28
+ onConfirm: () => resolve(true),
29
+ onCancel: () => resolve(false),
30
+ });
31
+ });
32
+
33
+ if (!confirmed) {
34
+ return;
35
+ }
36
+
37
+ try {
38
+ // Delete all selected tickets in parallel
39
+ await Promise.all(
40
+ selectedIds.map((id) =>
41
+ context.http.deleteTicket({
42
+ params: { id: Number(id) },
43
+ }),
44
+ ),
45
+ );
46
+
47
+ showToast(
48
+ `${ticketCount} ${ticketLabel} deleted successfully`,
49
+ "default",
50
+ );
51
+ } catch (error) {
52
+ console.error("Error deleting tickets:", error);
53
+ showToast(`Failed to delete ${ticketLabel}`, "error");
54
+ }
55
+ },
56
+ },
57
+ })
6
58
  .withViews({
7
59
  create: {
8
60
  type: "resource-create",
@@ -46,10 +98,13 @@ export const ticketResource = createResource("ticket")
46
98
  id: "search-tickets",
47
99
  title: "Search Tickets",
48
100
  schemaGet: (api) => api.getTickets.responseBody,
101
+ schemaFilter: (api) => api.getTickets.requestParams,
49
102
  breadcrumbs: () => [{ label: "Tickets", to: "/r/ticket/search" }],
103
+ tableActions: ["delete"],
104
+ rowActions: ["delete"],
50
105
  onLoad: async ({ context }) => {
51
106
  return context.http.getTickets({
52
- params: {},
107
+ params: context.params || {},
53
108
  });
54
109
  },
55
110
  },
@@ -1,4 +1,5 @@
1
- import { createResource } from "@nubase/frontend";
1
+ import { createResource, showToast } from "@nubase/frontend";
2
+ import { TrashIcon } from "lucide-react";
2
3
  import { apiEndpoints } from "schema";
3
4
 
4
5
  export const userResource = createResource("user")
@@ -7,4 +8,105 @@ export const userResource = createResource("user")
7
8
  onSearch: ({ query, context }) =>
8
9
  context.http.lookupUsers({ params: { q: query } }),
9
10
  })
10
- .withViews({});
11
+ .withActions({
12
+ delete: {
13
+ label: "Remove",
14
+ icon: TrashIcon,
15
+ variant: "destructive" as const,
16
+ onExecute: async ({ selectedIds, context }) => {
17
+ if (!selectedIds || selectedIds.length === 0) {
18
+ showToast("No users selected for removal", "error");
19
+ return;
20
+ }
21
+
22
+ const userCount = selectedIds.length;
23
+ const userLabel = userCount === 1 ? "user" : "users";
24
+
25
+ // Show confirmation dialog
26
+ const confirmed = await new Promise<boolean>((resolve) => {
27
+ context.dialog.openDialog({
28
+ title: "Remove Users",
29
+ content: `Are you sure you want to remove ${userCount} ${userLabel}? This action cannot be undone.`,
30
+ confirmText: "Remove",
31
+ confirmVariant: "destructive",
32
+ onConfirm: () => resolve(true),
33
+ onCancel: () => resolve(false),
34
+ });
35
+ });
36
+
37
+ if (!confirmed) {
38
+ return;
39
+ }
40
+
41
+ try {
42
+ // Remove all selected users in parallel
43
+ await Promise.all(
44
+ selectedIds.map((id) =>
45
+ context.http.deleteUser({
46
+ params: { id: Number(id) },
47
+ }),
48
+ ),
49
+ );
50
+
51
+ showToast(
52
+ `${userCount} ${userLabel} removed successfully`,
53
+ "default",
54
+ );
55
+ } catch (error) {
56
+ console.error("Error removing users:", error);
57
+ showToast(`Failed to remove ${userLabel}`, "error");
58
+ }
59
+ },
60
+ },
61
+ })
62
+ .withViews({
63
+ create: {
64
+ type: "resource-create",
65
+ id: "create-user",
66
+ title: "Add User",
67
+ schemaPost: (api) => api.postUser.requestBody,
68
+ breadcrumbs: [{ label: "Users", to: "/r/user/search" }, "Add User"],
69
+ onSubmit: async ({ data, context }) => {
70
+ return context.http.postUser({ data });
71
+ },
72
+ },
73
+ view: {
74
+ type: "resource-view",
75
+ id: "view-user",
76
+ title: "View User",
77
+ schemaGet: (api) => api.getUser.responseBody.omit("id"),
78
+ schemaParams: (api) => api.getUser.requestParams,
79
+ breadcrumbs: ({ context, data }) => [
80
+ { label: "Users", to: "/r/user/search" },
81
+ {
82
+ label:
83
+ data?.displayName || `User #${context.params?.id || "Unknown"}`,
84
+ },
85
+ ],
86
+ onLoad: async ({ context }) => {
87
+ return context.http.getUser({
88
+ params: { id: context.params.id },
89
+ });
90
+ },
91
+ onPatch: async ({ data, context }) => {
92
+ return context.http.patchUser({
93
+ params: { id: context.params.id },
94
+ data: data,
95
+ });
96
+ },
97
+ },
98
+ search: {
99
+ type: "resource-search",
100
+ id: "search-users",
101
+ title: "Users",
102
+ schemaGet: (api) => api.getUsers.responseBody,
103
+ breadcrumbs: () => [{ label: "Users", to: "/r/user/search" }],
104
+ tableActions: ["delete"],
105
+ rowActions: ["delete"],
106
+ onLoad: async ({ context }) => {
107
+ return context.http.getUsers({
108
+ params: {},
109
+ });
110
+ },
111
+ },
112
+ });
@@ -21,7 +21,14 @@ import {
21
21
  patchTicketSchema,
22
22
  postTicketSchema,
23
23
  } from "./endpoints/ticket";
24
- import { lookupUsersSchema } from "./endpoints/user";
24
+ import {
25
+ deleteUserSchema,
26
+ getUserSchema,
27
+ getUsersSchema,
28
+ lookupUsersSchema,
29
+ patchUserSchema,
30
+ postUserSchema,
31
+ } from "./endpoints/user";
25
32
 
26
33
  export const apiEndpoints = {
27
34
  // Auth
@@ -40,6 +47,11 @@ export const apiEndpoints = {
40
47
  deleteTicket: deleteTicketSchema,
41
48
 
42
49
  // Users
50
+ getUsers: getUsersSchema,
51
+ getUser: getUserSchema,
52
+ postUser: postUserSchema,
53
+ patchUser: patchUserSchema,
54
+ deleteUser: deleteUserSchema,
43
55
  lookupUsers: lookupUsersSchema,
44
56
 
45
57
  // Dashboard widgets
@@ -1,3 +1,4 @@
1
1
  export * from "./auth";
2
2
  export * from "./dashboard";
3
3
  export * from "./ticket";
4
+ export * from "./user";
@@ -0,0 +1,12 @@
1
+ import {
2
+ idNumberSchema,
3
+ type RequestSchema,
4
+ successSchema,
5
+ } from "@nubase/core";
6
+
7
+ export const deleteUserSchema = {
8
+ method: "DELETE" as const,
9
+ path: "/users/:id",
10
+ requestParams: idNumberSchema,
11
+ responseBody: successSchema,
12
+ } satisfies RequestSchema;
@@ -0,0 +1,9 @@
1
+ import { idNumberSchema, type RequestSchema } from "@nubase/core";
2
+ import { userSchema } from "../../resources/user";
3
+
4
+ export const getUserSchema = {
5
+ method: "GET" as const,
6
+ path: "/users/:id",
7
+ requestParams: idNumberSchema,
8
+ responseBody: userSchema,
9
+ } satisfies RequestSchema;
@@ -0,0 +1,9 @@
1
+ import { nu, type RequestSchema } from "@nubase/core";
2
+ import { userSchema } from "../../resources/user";
3
+
4
+ export const getUsersSchema = {
5
+ method: "GET" as const,
6
+ path: "/users",
7
+ requestParams: userSchema.omit("id").partial(),
8
+ responseBody: nu.array(userSchema),
9
+ } satisfies RequestSchema;
@@ -1 +1,6 @@
1
+ export { deleteUserSchema } from "./delete-user";
2
+ export { getUserSchema } from "./get-user";
3
+ export { getUsersSchema } from "./get-users";
1
4
  export { lookupUsersSchema } from "./lookup-users";
5
+ export { patchUserSchema } from "./patch-user";
6
+ export { postUserSchema } from "./post-user";
@@ -1,3 +1,3 @@
1
- import { createLookupEndpoint } from "@nubase/core";
1
+ import { createLookupEndpoint, nu } from "@nubase/core";
2
2
 
3
- export const lookupUsersSchema = createLookupEndpoint("users");
3
+ export const lookupUsersSchema = createLookupEndpoint("users", nu.number());
@@ -0,0 +1,10 @@
1
+ import { idNumberSchema, type RequestSchema } from "@nubase/core";
2
+ import { userSchema } from "../../resources/user";
3
+
4
+ export const patchUserSchema = {
5
+ method: "PATCH" as const,
6
+ path: "/users/:id",
7
+ requestParams: idNumberSchema,
8
+ requestBody: userSchema.omit("id").partial(),
9
+ responseBody: userSchema,
10
+ } satisfies RequestSchema;
@@ -0,0 +1,10 @@
1
+ import { emptySchema, type RequestSchema } from "@nubase/core";
2
+ import { userSchema } from "../../resources/user";
3
+
4
+ export const postUserSchema = {
5
+ method: "POST" as const,
6
+ path: "/users",
7
+ requestParams: emptySchema,
8
+ requestBody: userSchema.omit("id"),
9
+ responseBody: userSchema,
10
+ } satisfies RequestSchema;
@@ -12,6 +12,12 @@ export const ticketSchema = nu
12
12
  description: "Enter the description of the ticket",
13
13
  renderer: "multiline",
14
14
  }),
15
+ assigneeId: nu.number().optional().withMeta({
16
+ label: "Assignee",
17
+ description: "Select a user to assign this ticket to",
18
+ renderer: "lookup",
19
+ lookupResource: "user",
20
+ }),
15
21
  })
16
22
  .withId("id")
17
23
  .withTableLayouts({
@@ -20,6 +26,7 @@ export const ticketSchema = nu
20
26
  { name: "id", columnWidthPx: 80, pinned: true },
21
27
  { name: "title", columnWidthPx: 300, pinned: true },
22
28
  { name: "description", columnWidthPx: 400 },
29
+ { name: "assigneeId", columnWidthPx: 150 },
23
30
  ],
24
31
  metadata: {
25
32
  linkFields: ["title"],
@@ -1,10 +1,30 @@
1
1
  import { nu } from "@nubase/core";
2
2
 
3
3
  /**
4
- * User schema for authenticated user data
4
+ * User entity schema for authenticated user data
5
5
  */
6
- export const userSchema = nu.object({
7
- id: nu.number(),
8
- email: nu.string(),
9
- displayName: nu.string(),
10
- });
6
+ export const userSchema = nu
7
+ .object({
8
+ id: nu.number(),
9
+ email: nu.string().withMeta({
10
+ label: "Email",
11
+ description: "The user's email address",
12
+ }),
13
+ displayName: nu.string().withMeta({
14
+ label: "Display Name",
15
+ description: "The user's display name",
16
+ }),
17
+ })
18
+ .withId("id")
19
+ .withTableLayouts({
20
+ default: {
21
+ fields: [
22
+ { name: "id", columnWidthPx: 80, pinned: true },
23
+ { name: "displayName", columnWidthPx: 200, pinned: true },
24
+ { name: "email", columnWidthPx: 300 },
25
+ ],
26
+ metadata: {
27
+ linkFields: ["displayName"],
28
+ },
29
+ },
30
+ });