create-ej-app 16.0.0 → 18.0.0
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/README.md +108 -13
- package/cli.js +32 -19
- package/cli.ts +45 -27
- package/package.json +2 -2
- package/template/api/.env.example +3 -0
- package/template/api/.github/copilot-instructions.md +129 -0
- package/template/api/.vscode/extensions.json +9 -0
- package/template/api/.vscode/settings.json +3 -0
- package/template/api/Dockerfile +23 -0
- package/template/api/package.json +31 -0
- package/template/api/prettier.config.mjs +11 -0
- package/template/api/src/data-access/todo.ts +142 -0
- package/template/api/src/db/fields.ts +46 -0
- package/template/api/src/db/index.ts +21 -0
- package/template/api/src/db/schema.ts +19 -0
- package/template/api/src/env.ts +24 -0
- package/template/api/src/index.ts +39 -0
- package/template/api/src/lib/util.ts +55 -0
- package/template/api/src/routes/index.ts +18 -0
- package/template/api/src/routes/todo/index.ts +241 -0
- package/template/api/src/scripts/seed.ts +25 -0
- package/template/api/tsconfig.json +33 -0
- package/template/nextjs/drizzle.config.ts +10 -0
- package/template/{base → nextjs}/package.json +7 -4
- package/template/{base → nextjs}/src/app/not-found.tsx +8 -2
- package/template/nextjs/src/instrumentation.ts +26 -0
- package/template/nextjs/src/lib/otel.ts +27 -0
- package/template/{base → nextjs}/src/lib/safe-action.ts +8 -0
- /package/template/{base → api}/drizzle.config.ts +0 -0
- /package/template/{base → nextjs}/.dockerignore +0 -0
- /package/template/{base → nextjs}/.env.example +0 -0
- /package/template/{base → nextjs}/Dockerfile +0 -0
- /package/template/{base → nextjs}/components.json +0 -0
- /package/template/{base → nextjs}/eslint.config.mjs +0 -0
- /package/template/{base → nextjs}/next.config.ts +0 -0
- /package/template/{base → nextjs}/postcss.config.mjs +0 -0
- /package/template/{base → nextjs}/prettier.config.mjs +0 -0
- /package/template/{base → nextjs}/public/placeholder.svg +0 -0
- /package/template/{base → nextjs}/src/app/(admin)/admin/page.tsx +0 -0
- /package/template/{base → nextjs}/src/app/(admin)/layout.tsx +0 -0
- /package/template/{base → nextjs}/src/app/(app)/layout.tsx +0 -0
- /package/template/{base → nextjs}/src/app/(auth)/login/email-login-form.tsx +0 -0
- /package/template/{base → nextjs}/src/app/(auth)/login/page.tsx +0 -0
- /package/template/{base → nextjs}/src/app/(auth)/login/social-login-buttons.tsx +0 -0
- /package/template/{base → nextjs}/src/app/(marketing)/layout.tsx +0 -0
- /package/template/{base → nextjs}/src/app/(marketing)/page.tsx +0 -0
- /package/template/{base → nextjs}/src/app/(marketing)/privacy/page.tsx +0 -0
- /package/template/{base → nextjs}/src/app/(marketing)/terms/page.tsx +0 -0
- /package/template/{base → nextjs}/src/app/api/[[...slugs]]/route.ts +0 -0
- /package/template/{base → nextjs}/src/app/api/auth/[...all]/route.ts +0 -0
- /package/template/{base → nextjs}/src/app/favicon.ico +0 -0
- /package/template/{base → nextjs}/src/app/globals.css +0 -0
- /package/template/{base → nextjs}/src/app/layout.tsx +0 -0
- /package/template/{base → nextjs}/src/components/loader.tsx +0 -0
- /package/template/{base → nextjs}/src/components/react-query-provider.tsx +0 -0
- /package/template/{base → nextjs}/src/components/screen-size.tsx +0 -0
- /package/template/{base → nextjs}/src/components/site-footer.tsx +0 -0
- /package/template/{base → nextjs}/src/components/theme-toggle.tsx +0 -0
- /package/template/{base → nextjs}/src/components/ui/button.tsx +0 -0
- /package/template/{base → nextjs}/src/components/ui/card.tsx +0 -0
- /package/template/{base → nextjs}/src/components/ui/form.tsx +0 -0
- /package/template/{base → nextjs}/src/components/ui/input.tsx +0 -0
- /package/template/{base → nextjs}/src/components/ui/label.tsx +0 -0
- /package/template/{base → nextjs}/src/components/ui/separator.tsx +0 -0
- /package/template/{base → nextjs}/src/components/ui/sonner.tsx +0 -0
- /package/template/{base → nextjs}/src/db/index.ts +0 -0
- /package/template/{base → nextjs}/src/db/schema.ts +0 -0
- /package/template/{base → nextjs}/src/emails/components/email-layout.tsx +0 -0
- /package/template/{base → nextjs}/src/emails/magic-link.tsx +0 -0
- /package/template/{base → nextjs}/src/emails/test.tsx +0 -0
- /package/template/{base → nextjs}/src/emails/welcome.tsx +0 -0
- /package/template/{base → nextjs}/src/env.ts +0 -0
- /package/template/{base → nextjs}/src/lib/auth-client.ts +0 -0
- /package/template/{base → nextjs}/src/lib/auth.ts +0 -0
- /package/template/{base → nextjs}/src/lib/cn.ts +0 -0
- /package/template/{base → nextjs}/src/lib/delay.ts +0 -0
- /package/template/{base → nextjs}/src/lib/eden.ts +0 -0
- /package/template/{base → nextjs}/src/lib/email-service.ts +0 -0
- /package/template/{base → nextjs}/src/lib/nodemailer.ts +0 -0
- /package/template/{base → nextjs}/src/lib/safe-action-client.ts +0 -0
- /package/template/{base → nextjs}/src/lib/siteConfig.ts +0 -0
- /package/template/{base → nextjs}/src/lib/toast-message.ts +0 -0
- /package/template/{base → nextjs}/src/scripts/test-mail.ts +0 -0
- /package/template/{base → nextjs}/terraform/artifact-registry.tf +0 -0
- /package/template/{base → nextjs}/terraform/backend-service.tf +0 -0
- /package/template/{base → nextjs}/terraform/cleanup.sh +0 -0
- /package/template/{base → nextjs}/terraform/cloudbuild.tf +0 -0
- /package/template/{base → nextjs}/terraform/cloudrun.tf +0 -0
- /package/template/{base → nextjs}/terraform/database.tf +0 -0
- /package/template/{base → nextjs}/terraform/forwarding-rule.tf +0 -0
- /package/template/{base → nextjs}/terraform/ip-address.tf +0 -0
- /package/template/{base → nextjs}/terraform/network-endpoint-group.tf +0 -0
- /package/template/{base → nextjs}/terraform/network.tf +0 -0
- /package/template/{base → nextjs}/terraform/pre.sh +0 -0
- /package/template/{base → nextjs}/terraform/providers.tf +0 -0
- /package/template/{base → nextjs}/terraform/secret.txt +0 -0
- /package/template/{base → nextjs}/terraform/setup.sh +0 -0
- /package/template/{base → nextjs}/terraform/ssl-certs.tf +0 -0
- /package/template/{base → nextjs}/terraform/target-https-proxy copy.tf +0 -0
- /package/template/{base → nextjs}/terraform/target-https-proxy.tf +0 -0
- /package/template/{base → nextjs}/terraform/terraform.tfvars +0 -0
- /package/template/{base → nextjs}/terraform/url-map.tf +0 -0
- /package/template/{base → nextjs}/terraform/variables.tf +0 -0
- /package/template/{base → nextjs}/tsconfig.json +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data-access layer for the `todos` resource.
|
|
3
|
+
*
|
|
4
|
+
* All direct database interactions for todos are encapsulated here.
|
|
5
|
+
* Route handlers call these functions and never import `db` directly,
|
|
6
|
+
* keeping database logic separate from HTTP concerns.
|
|
7
|
+
*/
|
|
8
|
+
import { asc, count, desc, eq, ilike, InferSelectModel, or } from 'drizzle-orm'
|
|
9
|
+
import { db } from '~/db'
|
|
10
|
+
import { todosTable } from '~/db/schema'
|
|
11
|
+
import { SortOrderEnum } from '~/lib/util'
|
|
12
|
+
|
|
13
|
+
type TodoType = InferSelectModel<typeof todosTable>
|
|
14
|
+
|
|
15
|
+
/** Input parameters accepted by {@link getTodos}. */
|
|
16
|
+
type GetTodosInput = {
|
|
17
|
+
page: number
|
|
18
|
+
limit: number
|
|
19
|
+
sortBy?: keyof TodoType
|
|
20
|
+
sortOrder?: SortOrderEnum
|
|
21
|
+
search?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetches a paginated, optionally sorted and searched list of todos.
|
|
26
|
+
*
|
|
27
|
+
* @returns A readonly tuple `[todos, total]`.
|
|
28
|
+
*/
|
|
29
|
+
export const getTodos = async ({
|
|
30
|
+
page,
|
|
31
|
+
limit,
|
|
32
|
+
sortBy = 'createdAt',
|
|
33
|
+
sortOrder = SortOrderEnum.DESC,
|
|
34
|
+
search
|
|
35
|
+
}: GetTodosInput) => {
|
|
36
|
+
const where = search
|
|
37
|
+
? or(
|
|
38
|
+
ilike(todosTable.title, `%${search}%`),
|
|
39
|
+
ilike(todosTable.content, `%${search}%`)
|
|
40
|
+
)
|
|
41
|
+
: undefined
|
|
42
|
+
|
|
43
|
+
const [results, total] = await Promise.all([
|
|
44
|
+
db.query.todosTable.findMany({
|
|
45
|
+
where,
|
|
46
|
+
limit,
|
|
47
|
+
offset: (page - 1) * limit,
|
|
48
|
+
orderBy: [
|
|
49
|
+
sortOrder === SortOrderEnum.ASC
|
|
50
|
+
? asc(todosTable[sortBy])
|
|
51
|
+
: desc(todosTable[sortBy])
|
|
52
|
+
]
|
|
53
|
+
}),
|
|
54
|
+
db
|
|
55
|
+
.select({
|
|
56
|
+
count: count()
|
|
57
|
+
})
|
|
58
|
+
.from(todosTable)
|
|
59
|
+
.where(where)
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
return [results, total[0]?.count || 0] as const
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fetches a single todo by its UUID.
|
|
67
|
+
* @returns The todo row, or `undefined` if no match was found.
|
|
68
|
+
*/
|
|
69
|
+
export const getTodoById = async (id: string) => {
|
|
70
|
+
return await db.query.todosTable.findFirst({
|
|
71
|
+
where: eq(todosTable.id, id)
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Input shape for creating a new todo. */
|
|
76
|
+
type CreateTodoInput = {
|
|
77
|
+
title: string
|
|
78
|
+
content?: string | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Inserts a new todo row and returns the generated UUID.
|
|
83
|
+
* @throws If the INSERT succeeds but no id is returned (should never happen).
|
|
84
|
+
*/
|
|
85
|
+
export const createTodo = async (input: CreateTodoInput) => {
|
|
86
|
+
const [todo] = await db
|
|
87
|
+
.insert(todosTable)
|
|
88
|
+
.values({
|
|
89
|
+
title: input.title,
|
|
90
|
+
content: input.content
|
|
91
|
+
})
|
|
92
|
+
.returning({
|
|
93
|
+
id: todosTable.id
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (!todo?.id) {
|
|
97
|
+
throw new Error('Failed to create todo or not returning id')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return todo.id
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Input shape for partially updating an existing todo. */
|
|
104
|
+
type UpdateTodoInput = {
|
|
105
|
+
id: string
|
|
106
|
+
title?: string
|
|
107
|
+
content?: string | null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Updates an existing todo by UUID.
|
|
112
|
+
* Only the fields present in `input` (besides `id`) are written.
|
|
113
|
+
* @returns `true` if a row was updated, `false` if the id was not found.
|
|
114
|
+
*/
|
|
115
|
+
export const updateTodoById = async (input: UpdateTodoInput) => {
|
|
116
|
+
const { id, ...rest } = input
|
|
117
|
+
|
|
118
|
+
const [todo] = await db
|
|
119
|
+
.update(todosTable)
|
|
120
|
+
.set(rest)
|
|
121
|
+
.where(eq(todosTable.id, id))
|
|
122
|
+
.returning({
|
|
123
|
+
id: todosTable.id
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return !!todo?.id
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Deletes a todo by UUID.
|
|
131
|
+
* @returns `true` if a row was deleted, `false` if the id was not found.
|
|
132
|
+
*/
|
|
133
|
+
export const deleteTodoById = async (id: string) => {
|
|
134
|
+
const [todo] = await db
|
|
135
|
+
.delete(todosTable)
|
|
136
|
+
.where(eq(todosTable.id, id))
|
|
137
|
+
.returning({
|
|
138
|
+
id: todosTable.id
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
return !!todo?.id
|
|
142
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared / reusable column definitions for Drizzle schemas.
|
|
3
|
+
*
|
|
4
|
+
* Centralising common column definitions here keeps every table
|
|
5
|
+
* consistent (same naming, same types, same defaults) and eliminates
|
|
6
|
+
* duplication across schema files.
|
|
7
|
+
*/
|
|
8
|
+
import { timestamp, uuid } from 'drizzle-orm/pg-core'
|
|
9
|
+
|
|
10
|
+
export const commonFieldDefs = {
|
|
11
|
+
/**
|
|
12
|
+
* Standard UUID primary key column.
|
|
13
|
+
* The database generates a random UUID v4 by default.
|
|
14
|
+
*/
|
|
15
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Factory for a single timezone-aware timestamp column.
|
|
19
|
+
* @param name – The underlying database column name.
|
|
20
|
+
*/
|
|
21
|
+
date: (name: string) =>
|
|
22
|
+
timestamp(name, {
|
|
23
|
+
mode: 'date',
|
|
24
|
+
withTimezone: true
|
|
25
|
+
}),
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Standard `created_at` / `updated_at` audit columns.
|
|
29
|
+
* Both default to the current time at insert; `updated_at` should
|
|
30
|
+
* be refreshed on every UPDATE (via application logic or a trigger).
|
|
31
|
+
*/
|
|
32
|
+
dates: {
|
|
33
|
+
createdAt: timestamp('created_at', {
|
|
34
|
+
mode: 'date',
|
|
35
|
+
withTimezone: true
|
|
36
|
+
})
|
|
37
|
+
.notNull()
|
|
38
|
+
.defaultNow(),
|
|
39
|
+
updatedAt: timestamp('updated_at', {
|
|
40
|
+
mode: 'date',
|
|
41
|
+
withTimezone: true
|
|
42
|
+
})
|
|
43
|
+
.notNull()
|
|
44
|
+
.defaultNow()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle ORM database client.
|
|
3
|
+
*
|
|
4
|
+
* Creates and exports the singleton `db` instance that is used
|
|
5
|
+
* throughout the application to interact with the PostgreSQL database.
|
|
6
|
+
* The Bun-native SQL driver (`bun-sql`) is used for maximum performance.
|
|
7
|
+
*
|
|
8
|
+
* All table definitions from the schema are passed in so that Drizzle's
|
|
9
|
+
* relational query API (`db.query.*`) is fully typed.
|
|
10
|
+
*/
|
|
11
|
+
import { drizzle } from 'drizzle-orm/bun-sql'
|
|
12
|
+
import { env } from '~/env'
|
|
13
|
+
import * as schema from './schema'
|
|
14
|
+
|
|
15
|
+
// Instantiate the Drizzle client with the validated DATABASE_URL and the
|
|
16
|
+
// full schema so relational queries (db.query.todosTable.*) are available.
|
|
17
|
+
export const db = drizzle(env.DATABASE_URL, {
|
|
18
|
+
schema: {
|
|
19
|
+
...schema
|
|
20
|
+
}
|
|
21
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database schema definitions.
|
|
3
|
+
*
|
|
4
|
+
* All Drizzle table definitions live here and are imported by both the
|
|
5
|
+
* Drizzle client (`db/index.ts`) and the Drizzle Kit config
|
|
6
|
+
* (`drizzle.config.ts`) so that migrations stay in sync with the code.
|
|
7
|
+
*/
|
|
8
|
+
import { pgTable, text } from 'drizzle-orm/pg-core'
|
|
9
|
+
import { commonFieldDefs } from './fields'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `todos` table – stores individual todo items.
|
|
13
|
+
*/
|
|
14
|
+
export const todosTable = pgTable('todos', {
|
|
15
|
+
id: commonFieldDefs.id,
|
|
16
|
+
title: text('title').notNull(),
|
|
17
|
+
content: text('content'),
|
|
18
|
+
...commonFieldDefs.dates
|
|
19
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validated environment configuration.
|
|
3
|
+
*
|
|
4
|
+
* Uses @t3-oss/env-core together with Zod to parse and validate all
|
|
5
|
+
* required environment variables at startup. The application will
|
|
6
|
+
* throw a descriptive error and refuse to start if any variable is
|
|
7
|
+
* missing or fails its validation rule.
|
|
8
|
+
*/
|
|
9
|
+
import { createEnv } from '@t3-oss/env-core'
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
|
|
12
|
+
export const env = createEnv({
|
|
13
|
+
server: {
|
|
14
|
+
PORT: z.coerce.number().int().positive(),
|
|
15
|
+
APP_ENV: z.enum(['development', 'staging', 'production']),
|
|
16
|
+
DATABASE_URL: z.url()
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
// Read all variables directly from process.env
|
|
20
|
+
runtimeEnv: process.env,
|
|
21
|
+
|
|
22
|
+
// Treat empty strings the same as missing (undefined) variables
|
|
23
|
+
emptyStringAsUndefined: true
|
|
24
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application entry point.
|
|
3
|
+
*
|
|
4
|
+
* Bootstraps the Elysia HTTP server, registers all route plugins,
|
|
5
|
+
* mounts the OpenAPI documentation UI, and starts
|
|
6
|
+
* listening on the port defined in the environment.
|
|
7
|
+
*/
|
|
8
|
+
import { openapi } from '@elysiajs/openapi'
|
|
9
|
+
import { Elysia } from 'elysia'
|
|
10
|
+
import { env } from './env'
|
|
11
|
+
import { apiRoutes } from './routes'
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
const app = new Elysia()
|
|
15
|
+
|
|
16
|
+
// Simple liveness probe used by load-balancers and container orchestrators
|
|
17
|
+
app.get('/health', () => 'OK', {
|
|
18
|
+
detail: {
|
|
19
|
+
description: 'Health check endpoint'
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// Mount all /api/* routes (todo CRUD, etc.)
|
|
24
|
+
app.use(apiRoutes)
|
|
25
|
+
|
|
26
|
+
// Serve interactive OpenAPI docs at GET /docs
|
|
27
|
+
app.use(
|
|
28
|
+
openapi({
|
|
29
|
+
path: '/docs'
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// Start the Bun HTTP server on the configured port
|
|
34
|
+
app.listen(env.PORT)
|
|
35
|
+
|
|
36
|
+
console.log(`🚀 Server ready at ${app.server?.hostname}:${app.server?.port}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
main()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utility types, enums, and reusable Zod schema fragments.
|
|
3
|
+
*/
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Allowed values for the `sortOrder` query parameter.
|
|
8
|
+
* Maps to the SQL `ASC` / `DESC` keywords.
|
|
9
|
+
*/
|
|
10
|
+
export enum SortOrderEnum {
|
|
11
|
+
ASC = 'asc',
|
|
12
|
+
DESC = 'desc'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reusable Zod schema fragment for cursor-less offset pagination.
|
|
17
|
+
*
|
|
18
|
+
* - `page` – 1-based page number (defaults to 1)
|
|
19
|
+
* - `limit` – Number of items per page; clamped between 10 and 100 (defaults to 10)
|
|
20
|
+
*
|
|
21
|
+
* Spread this object into a `z.object({})` call to add pagination
|
|
22
|
+
* to any route's query schema.
|
|
23
|
+
*/
|
|
24
|
+
export const paginationSchema = {
|
|
25
|
+
page: z.coerce.number().int().positive().optional().default(1),
|
|
26
|
+
limit: z.coerce
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.positive()
|
|
30
|
+
.min(10)
|
|
31
|
+
.max(100)
|
|
32
|
+
.optional()
|
|
33
|
+
.default(10)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generates a reusable Zod schema fragment for sort parameters.
|
|
38
|
+
*
|
|
39
|
+
* @param keys – A non-empty tuple of the allowed `sortBy` field names for
|
|
40
|
+
* the resource (e.g. `['title', 'createdAt', 'updatedAt']`).
|
|
41
|
+
* @returns An object with `sortOrder` and `sortBy` Zod validators that can
|
|
42
|
+
* be spread into a `z.object({})` query schema.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* z.object({
|
|
46
|
+
* ...paginationSchema,
|
|
47
|
+
* ...getSortSchema(['title', 'createdAt', 'updatedAt'])
|
|
48
|
+
* })
|
|
49
|
+
*/
|
|
50
|
+
export const getSortSchema = <T extends string>(
|
|
51
|
+
keys: readonly [T, ...T[]]
|
|
52
|
+
) => ({
|
|
53
|
+
sortOrder: z.enum(SortOrderEnum).optional(),
|
|
54
|
+
sortBy: z.enum(keys).optional()
|
|
55
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level API router.
|
|
3
|
+
*
|
|
4
|
+
* All REST resources are registered here under the `/api` prefix and
|
|
5
|
+
* then mounted onto the root Elysia app in `src/index.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Adding a new resource:
|
|
8
|
+
* 1. Create its router in `src/routes/<resource>/index.ts`.
|
|
9
|
+
* 2. Import it here and call `apiRoutes.use(yourRouter)`.
|
|
10
|
+
*/
|
|
11
|
+
import Elysia from 'elysia'
|
|
12
|
+
import { todoRouter } from './todo'
|
|
13
|
+
|
|
14
|
+
export const apiRoutes = new Elysia({
|
|
15
|
+
prefix: '/api'
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
apiRoutes.use(todoRouter)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todos resource router.
|
|
3
|
+
*
|
|
4
|
+
* Defines the full CRUD REST API for the `todos` resource mounted at
|
|
5
|
+
* `/api/todos`. Every handler delegates database work to the data-access
|
|
6
|
+
* layer (`~/data-access/todo`) and uses Zod schemas for request
|
|
7
|
+
* validation so that invalid input is rejected before it reaches the DB.
|
|
8
|
+
*
|
|
9
|
+
* Endpoints:
|
|
10
|
+
* GET /api/todos – List todos with pagination, sorting & search
|
|
11
|
+
* GET /api/todos/:id – Fetch a single todo by UUID
|
|
12
|
+
* POST /api/todos – Create a new todo
|
|
13
|
+
* PATCH /api/todos/:id – Partially update an existing todo
|
|
14
|
+
* DELETE /api/todos/:id – Delete a todo
|
|
15
|
+
*/
|
|
16
|
+
import Elysia, { status } from 'elysia'
|
|
17
|
+
import { z } from 'zod'
|
|
18
|
+
import {
|
|
19
|
+
createTodo,
|
|
20
|
+
deleteTodoById,
|
|
21
|
+
getTodoById,
|
|
22
|
+
getTodos,
|
|
23
|
+
updateTodoById
|
|
24
|
+
} from '~/data-access/todo'
|
|
25
|
+
import { getSortSchema, paginationSchema } from '~/lib/util'
|
|
26
|
+
|
|
27
|
+
export const todoRouter = new Elysia({ prefix: '/todos' })
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* GET /api/todos
|
|
31
|
+
* Returns a paginated, optionally sorted and searched list of todos.
|
|
32
|
+
* Supports full-text search across `title` and `content` columns.
|
|
33
|
+
*/
|
|
34
|
+
todoRouter.get(
|
|
35
|
+
'/',
|
|
36
|
+
async ({ query }) => {
|
|
37
|
+
try {
|
|
38
|
+
const [todos, count] = await getTodos(query)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
success: true,
|
|
42
|
+
data: {
|
|
43
|
+
page: query.page,
|
|
44
|
+
limit: query.limit,
|
|
45
|
+
hasMore: query.page * query.limit < count,
|
|
46
|
+
todos,
|
|
47
|
+
count
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error('Error in GET /api/todos', err)
|
|
52
|
+
return status(500, {
|
|
53
|
+
success: false,
|
|
54
|
+
error: 'Something went wrong.'
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
query: z.object({
|
|
60
|
+
...paginationSchema,
|
|
61
|
+
...getSortSchema(['title', 'createdAt', 'updatedAt']),
|
|
62
|
+
search: z.string().trim().min(1).max(100).optional()
|
|
63
|
+
}),
|
|
64
|
+
detail: {
|
|
65
|
+
description: 'Get all todos',
|
|
66
|
+
tags: ['Todos']
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* GET /api/todos/:id
|
|
73
|
+
* Fetches a single todo by its UUID. Returns 404 if not found.
|
|
74
|
+
*/
|
|
75
|
+
todoRouter.get(
|
|
76
|
+
'/:id',
|
|
77
|
+
async ({ params }) => {
|
|
78
|
+
try {
|
|
79
|
+
const todo = await getTodoById(params.id)
|
|
80
|
+
|
|
81
|
+
if (!todo) {
|
|
82
|
+
return status(404, {
|
|
83
|
+
success: false,
|
|
84
|
+
error: 'Todo not found'
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
success: true,
|
|
90
|
+
data: {
|
|
91
|
+
todo
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error('Error in GET /api/todos/:id', err)
|
|
96
|
+
return status(500, {
|
|
97
|
+
success: false,
|
|
98
|
+
error: 'Something went wrong.'
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
// Validate that :id is a well-formed UUID before hitting the DB
|
|
104
|
+
params: z.object({
|
|
105
|
+
id: z.uuid()
|
|
106
|
+
}),
|
|
107
|
+
detail: {
|
|
108
|
+
description: 'Get a todo by id',
|
|
109
|
+
tags: ['Todos']
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* POST /api/todos
|
|
116
|
+
* Creates a new todo from the request body. Returns the generated UUID.
|
|
117
|
+
*/
|
|
118
|
+
todoRouter.post(
|
|
119
|
+
'/',
|
|
120
|
+
async ({ body }) => {
|
|
121
|
+
try {
|
|
122
|
+
const id = await createTodo(body)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
success: true,
|
|
126
|
+
data: {
|
|
127
|
+
id
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.error('Error in POST /api/todos', err)
|
|
132
|
+
return status(500, {
|
|
133
|
+
success: false,
|
|
134
|
+
error: 'Something went wrong.'
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
body: z.object({
|
|
140
|
+
title: z.string().trim().min(1).max(255),
|
|
141
|
+
content: z.string().trim().max(5000).nullish()
|
|
142
|
+
}),
|
|
143
|
+
detail: {
|
|
144
|
+
description: 'Create a new todo',
|
|
145
|
+
tags: ['Todos']
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* PATCH /api/todos/:id
|
|
152
|
+
* Partially updates a todo. All body fields are optional — only provided
|
|
153
|
+
* fields are written to the database. Returns 404 if the todo does not exist.
|
|
154
|
+
*/
|
|
155
|
+
todoRouter.patch(
|
|
156
|
+
'/:id',
|
|
157
|
+
async ({ params, body }) => {
|
|
158
|
+
try {
|
|
159
|
+
const success = await updateTodoById({
|
|
160
|
+
id: params.id,
|
|
161
|
+
...body
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
if (!success) {
|
|
165
|
+
return status(404, {
|
|
166
|
+
success: false,
|
|
167
|
+
error: 'Todo not found'
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
success: true,
|
|
173
|
+
data: {
|
|
174
|
+
id: params.id
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error('Error in PATCH /api/todos/:id', err)
|
|
179
|
+
return status(500, {
|
|
180
|
+
success: false,
|
|
181
|
+
error: 'Something went wrong.'
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
params: z.object({
|
|
187
|
+
id: z.uuid()
|
|
188
|
+
}),
|
|
189
|
+
// Both fields are optional for a true partial-update (PATCH) semantic
|
|
190
|
+
body: z.object({
|
|
191
|
+
title: z.string().trim().min(1).max(255).optional(),
|
|
192
|
+
content: z.string().trim().max(5000).nullish()
|
|
193
|
+
}),
|
|
194
|
+
detail: {
|
|
195
|
+
description: 'Update a todo by id',
|
|
196
|
+
tags: ['Todos']
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* DELETE /api/todos/:id
|
|
203
|
+
* Permanently removes a todo by UUID. Returns 404 if not found.
|
|
204
|
+
*/
|
|
205
|
+
todoRouter.delete(
|
|
206
|
+
'/:id',
|
|
207
|
+
async ({ params }) => {
|
|
208
|
+
try {
|
|
209
|
+
const success = await deleteTodoById(params.id)
|
|
210
|
+
|
|
211
|
+
if (!success) {
|
|
212
|
+
return status(404, {
|
|
213
|
+
success: false,
|
|
214
|
+
error: 'Todo not found'
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
success: true,
|
|
220
|
+
data: {
|
|
221
|
+
id: params.id
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.error('Error in DELETE /api/todos/:id', err)
|
|
226
|
+
return status(500, {
|
|
227
|
+
success: false,
|
|
228
|
+
error: 'Something went wrong.'
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
params: z.object({
|
|
234
|
+
id: z.uuid()
|
|
235
|
+
}),
|
|
236
|
+
detail: {
|
|
237
|
+
description: 'Delete a todo by id',
|
|
238
|
+
tags: ['Todos']
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { db } from '~/db'
|
|
2
|
+
import { todosTable } from '~/db/schema'
|
|
3
|
+
|
|
4
|
+
const seedData = async () => {
|
|
5
|
+
await db.insert(todosTable).values([
|
|
6
|
+
{
|
|
7
|
+
title: 'Buy groceries',
|
|
8
|
+
content: 'Milk, Bread, Butter, Jam'
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
title: 'Finish project',
|
|
12
|
+
content: 'Complete the frontend and backend integration'
|
|
13
|
+
}
|
|
14
|
+
])
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (import.meta.main) {
|
|
18
|
+
try {
|
|
19
|
+
await seedData()
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.error('Error seeding data:', err)
|
|
22
|
+
} finally {
|
|
23
|
+
process.exit(0)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"checkJs": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noImplicitAny": true,
|
|
10
|
+
"strictNullChecks": true,
|
|
11
|
+
"strictFunctionTypes": true,
|
|
12
|
+
"noImplicitThis": true,
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"noUnusedLocals": true,
|
|
15
|
+
"noUnusedParameters": true,
|
|
16
|
+
"noImplicitReturns": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"allowUnreachableCode": false,
|
|
19
|
+
"forceConsistentCasingInFileNames": true,
|
|
20
|
+
"noEmit": true,
|
|
21
|
+
"esModuleInterop": true,
|
|
22
|
+
"module": "ESNext",
|
|
23
|
+
"moduleResolution": "bundler",
|
|
24
|
+
"resolveJsonModule": true,
|
|
25
|
+
"isolatedModules": true,
|
|
26
|
+
"incremental": true,
|
|
27
|
+
"paths": {
|
|
28
|
+
"~/*": ["./src/*"]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"include": ["**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"],
|
|
32
|
+
"exclude": ["node_modules"]
|
|
33
|
+
}
|