@yoms/create-monorepo 1.0.2
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/LICENSE +21 -0
- package/README.md +204 -0
- package/dist/index.js +649 -0
- package/package.json +74 -0
- package/templates/backend-hono/base/.env.example +7 -0
- package/templates/backend-hono/base/Dockerfile +55 -0
- package/templates/backend-hono/base/package.json +30 -0
- package/templates/backend-hono/base/src/config/env.ts +12 -0
- package/templates/backend-hono/base/src/config/logger.ts +52 -0
- package/templates/backend-hono/base/src/index.ts +49 -0
- package/templates/backend-hono/base/src/lib/__tests__/response.test.ts +66 -0
- package/templates/backend-hono/base/src/lib/errors.ts +87 -0
- package/templates/backend-hono/base/src/lib/response.ts +72 -0
- package/templates/backend-hono/base/src/middleware/__tests__/error.test.ts +86 -0
- package/templates/backend-hono/base/src/middleware/cors.middleware.ts +9 -0
- package/templates/backend-hono/base/src/middleware/error.middleware.ts +43 -0
- package/templates/backend-hono/base/src/middleware/logger.middleware.ts +14 -0
- package/templates/backend-hono/base/src/middleware/rate-limit.middleware.ts +135 -0
- package/templates/backend-hono/base/src/routes/__tests__/health.test.ts +36 -0
- package/templates/backend-hono/base/src/routes/health.route.ts +14 -0
- package/templates/backend-hono/base/src/types/app.types.ts +26 -0
- package/templates/backend-hono/base/tsconfig.json +10 -0
- package/templates/backend-hono/base/vitest.config.ts +19 -0
- package/templates/backend-hono/features/mongodb-prisma/env-additions.txt +2 -0
- package/templates/backend-hono/features/mongodb-prisma/package-additions.json +14 -0
- package/templates/backend-hono/features/mongodb-prisma/prisma/schema.prisma +18 -0
- package/templates/backend-hono/features/mongodb-prisma/src/config/database.ts +43 -0
- package/templates/backend-hono/features/mongodb-prisma/src/routes/users.route.ts +82 -0
- package/templates/backend-hono/features/mongodb-prisma/src/services/user.service.ts +121 -0
- package/templates/backend-hono/features/postgres-prisma/env-additions.txt +2 -0
- package/templates/backend-hono/features/postgres-prisma/package-additions.json +15 -0
- package/templates/backend-hono/features/postgres-prisma/prisma/schema.prisma +18 -0
- package/templates/backend-hono/features/postgres-prisma/src/config/database.ts +43 -0
- package/templates/backend-hono/features/postgres-prisma/src/routes/users.route.ts +82 -0
- package/templates/backend-hono/features/postgres-prisma/src/services/user.service.ts +121 -0
- package/templates/backend-hono/features/redis/env-additions.txt +2 -0
- package/templates/backend-hono/features/redis/package-additions.json +8 -0
- package/templates/backend-hono/features/redis/src/config/redis.ts +32 -0
- package/templates/backend-hono/features/redis/src/services/cache.service.ts +107 -0
- package/templates/backend-hono/features/smtp/env-additions.txt +7 -0
- package/templates/backend-hono/features/smtp/package-additions.json +8 -0
- package/templates/backend-hono/features/smtp/src/config/mail.ts +38 -0
- package/templates/backend-hono/features/smtp/src/services/email.service.ts +78 -0
- package/templates/backend-hono/features/swagger/package-additions.json +6 -0
- package/templates/backend-hono/features/swagger/src/lib/openapi.ts +19 -0
- package/templates/backend-hono/features/swagger/src/routes/docs.route.ts +18 -0
- package/templates/frontend-nextjs/base/.env.example +2 -0
- package/templates/frontend-nextjs/base/app/globals.css +59 -0
- package/templates/frontend-nextjs/base/app/layout.tsx +22 -0
- package/templates/frontend-nextjs/base/app/page.tsx +49 -0
- package/templates/frontend-nextjs/base/components.json +18 -0
- package/templates/frontend-nextjs/base/lib/api-client.ts +67 -0
- package/templates/frontend-nextjs/base/lib/utils.ts +6 -0
- package/templates/frontend-nextjs/base/next.config.ts +8 -0
- package/templates/frontend-nextjs/base/package.json +33 -0
- package/templates/frontend-nextjs/base/postcss.config.mjs +9 -0
- package/templates/frontend-nextjs/base/public/.gitkeep +1 -0
- package/templates/frontend-nextjs/base/tailwind.config.ts +58 -0
- package/templates/frontend-nextjs/base/tsconfig.json +19 -0
- package/templates/shared/base/package.json +19 -0
- package/templates/shared/base/src/index.ts +5 -0
- package/templates/shared/base/src/schemas/user.schema.ts +34 -0
- package/templates/shared/base/src/types.ts +46 -0
- package/templates/shared/base/tsconfig.json +9 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Context, Next } from 'hono';
|
|
2
|
+
import { TooManyRequestsError } from '../lib/errors.js';
|
|
3
|
+
import { logger } from '../config/logger.js';
|
|
4
|
+
|
|
5
|
+
interface RateLimitOptions {
|
|
6
|
+
windowMs: number; // Time window in milliseconds
|
|
7
|
+
max: number; // Max requests per window
|
|
8
|
+
message?: string;
|
|
9
|
+
keyGenerator?: (c: Context) => string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface RateLimitStore {
|
|
13
|
+
get(key: string): Promise<number | null>;
|
|
14
|
+
increment(key: string, ttl: number): Promise<number>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In-memory rate limit store
|
|
19
|
+
*/
|
|
20
|
+
class MemoryStore implements RateLimitStore {
|
|
21
|
+
private hits: Map<string, { count: number; resetTime: number }> = new Map();
|
|
22
|
+
|
|
23
|
+
async get(key: string): Promise<number | null> {
|
|
24
|
+
const entry = this.hits.get(key);
|
|
25
|
+
if (!entry) return null;
|
|
26
|
+
|
|
27
|
+
// Clean up expired entries
|
|
28
|
+
if (Date.now() > entry.resetTime) {
|
|
29
|
+
this.hits.delete(key);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return entry.count;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async increment(key: string, ttl: number): Promise<number> {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const entry = this.hits.get(key);
|
|
39
|
+
|
|
40
|
+
if (!entry || now > entry.resetTime) {
|
|
41
|
+
const resetTime = now + ttl;
|
|
42
|
+
this.hits.set(key, { count: 1, resetTime });
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
entry.count++;
|
|
47
|
+
return entry.count;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Cleanup old entries periodically
|
|
51
|
+
startCleanup(intervalMs: number = 60000) {
|
|
52
|
+
setInterval(() => {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
for (const [key, entry] of this.hits.entries()) {
|
|
55
|
+
if (now > entry.resetTime) {
|
|
56
|
+
this.hits.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}, intervalMs);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Create singleton memory store
|
|
64
|
+
const memoryStore = new MemoryStore();
|
|
65
|
+
memoryStore.startCleanup();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Rate limiting middleware factory
|
|
69
|
+
*/
|
|
70
|
+
export function rateLimit(options: RateLimitOptions) {
|
|
71
|
+
const {
|
|
72
|
+
windowMs,
|
|
73
|
+
max,
|
|
74
|
+
message = 'Too many requests, please try again later',
|
|
75
|
+
keyGenerator = (c) => c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown',
|
|
76
|
+
} = options;
|
|
77
|
+
|
|
78
|
+
return async (c: Context, next: Next) => {
|
|
79
|
+
const key = `rate-limit:${keyGenerator(c)}`;
|
|
80
|
+
const store = memoryStore;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const hits = await store.increment(key, windowMs);
|
|
84
|
+
|
|
85
|
+
// Set rate limit headers
|
|
86
|
+
c.header('X-RateLimit-Limit', max.toString());
|
|
87
|
+
c.header('X-RateLimit-Remaining', Math.max(0, max - hits).toString());
|
|
88
|
+
c.header('X-RateLimit-Reset', new Date(Date.now() + windowMs).toISOString());
|
|
89
|
+
|
|
90
|
+
if (hits > max) {
|
|
91
|
+
logger.warn('Rate limit exceeded:', { key, hits, max });
|
|
92
|
+
throw new TooManyRequestsError(message);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await next();
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof TooManyRequestsError) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
// If store fails, log but allow request
|
|
101
|
+
logger.error('Rate limit store error:', error);
|
|
102
|
+
await next();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Common rate limit configurations
|
|
109
|
+
*/
|
|
110
|
+
export const rateLimits = {
|
|
111
|
+
// Strict: 10 requests per minute
|
|
112
|
+
strict: rateLimit({
|
|
113
|
+
windowMs: 60 * 1000,
|
|
114
|
+
max: 10,
|
|
115
|
+
}),
|
|
116
|
+
|
|
117
|
+
// Standard: 100 requests per minute
|
|
118
|
+
standard: rateLimit({
|
|
119
|
+
windowMs: 60 * 1000,
|
|
120
|
+
max: 100,
|
|
121
|
+
}),
|
|
122
|
+
|
|
123
|
+
// Lenient: 1000 requests per minute
|
|
124
|
+
lenient: rateLimit({
|
|
125
|
+
windowMs: 60 * 1000,
|
|
126
|
+
max: 1000,
|
|
127
|
+
}),
|
|
128
|
+
|
|
129
|
+
// Auth: 5 login attempts per 15 minutes
|
|
130
|
+
auth: rateLimit({
|
|
131
|
+
windowMs: 15 * 60 * 1000,
|
|
132
|
+
max: 5,
|
|
133
|
+
message: 'Too many login attempts, please try again later',
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { health } from '../health.route.js';
|
|
4
|
+
|
|
5
|
+
describe('Health Route', () => {
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
app.route('/health', health);
|
|
8
|
+
|
|
9
|
+
it('should return 200 OK with health status', async () => {
|
|
10
|
+
const res = await app.request('/health');
|
|
11
|
+
expect(res.status).toBe(200);
|
|
12
|
+
|
|
13
|
+
const json = await res.json();
|
|
14
|
+
expect(json.success).toBe(true);
|
|
15
|
+
expect(json.data).toHaveProperty('status', 'ok');
|
|
16
|
+
expect(json.data).toHaveProperty('timestamp');
|
|
17
|
+
expect(json.data).toHaveProperty('uptime');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should return valid timestamp', async () => {
|
|
21
|
+
const res = await app.request('/health');
|
|
22
|
+
const json = await res.json();
|
|
23
|
+
|
|
24
|
+
const timestamp = new Date(json.data.timestamp);
|
|
25
|
+
expect(timestamp.getTime()).not.toBeNaN();
|
|
26
|
+
expect(timestamp.getTime()).toBeLessThanOrEqual(Date.now());
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return positive uptime', async () => {
|
|
30
|
+
const res = await app.request('/health');
|
|
31
|
+
const json = await res.json();
|
|
32
|
+
|
|
33
|
+
expect(json.data.uptime).toBeGreaterThan(0);
|
|
34
|
+
expect(typeof json.data.uptime).toBe('number');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { success } from '../lib/response.js';
|
|
3
|
+
|
|
4
|
+
const health = new Hono();
|
|
5
|
+
|
|
6
|
+
health.get('/', (c) => {
|
|
7
|
+
return success(c, {
|
|
8
|
+
status: 'ok',
|
|
9
|
+
timestamp: new Date().toISOString(),
|
|
10
|
+
uptime: process.uptime(),
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export { health };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
|
|
3
|
+
export type AppType = Hono;
|
|
4
|
+
|
|
5
|
+
export interface ApiResponse<T = unknown> {
|
|
6
|
+
success: boolean;
|
|
7
|
+
data?: T;
|
|
8
|
+
error?: string;
|
|
9
|
+
message?: string;
|
|
10
|
+
code?: string;
|
|
11
|
+
details?: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PaginationParams {
|
|
15
|
+
page: number;
|
|
16
|
+
limit: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
|
|
20
|
+
pagination: {
|
|
21
|
+
page: number;
|
|
22
|
+
limit: number;
|
|
23
|
+
total: number;
|
|
24
|
+
totalPages: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
coverage: {
|
|
8
|
+
provider: 'v8',
|
|
9
|
+
reporter: ['text', 'json', 'html'],
|
|
10
|
+
exclude: [
|
|
11
|
+
'node_modules/',
|
|
12
|
+
'dist/',
|
|
13
|
+
'**/*.test.ts',
|
|
14
|
+
'**/*.spec.ts',
|
|
15
|
+
'vitest.config.ts',
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@prisma/client": "^6.2.0",
|
|
4
|
+
"@hono/zod-validator": "^0.4.1"
|
|
5
|
+
},
|
|
6
|
+
"devDependencies": {
|
|
7
|
+
"prisma": "^6.2.0"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"prisma:generate": "prisma generate",
|
|
11
|
+
"prisma:push": "prisma db push",
|
|
12
|
+
"prisma:studio": "prisma studio"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
generator client {
|
|
2
|
+
provider = "prisma-client-js"
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
datasource db {
|
|
6
|
+
provider = "mongodb"
|
|
7
|
+
url = env("DATABASE_URL")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
model User {
|
|
11
|
+
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
12
|
+
email String @unique
|
|
13
|
+
name String?
|
|
14
|
+
createdAt DateTime @default(now())
|
|
15
|
+
updatedAt DateTime @updatedAt
|
|
16
|
+
|
|
17
|
+
@@map("users")
|
|
18
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { PrismaClient } from '@prisma/client';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
|
|
5
|
+
// Validate database environment variables
|
|
6
|
+
const dbEnvSchema = z.object({
|
|
7
|
+
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const dbEnv = dbEnvSchema.parse(process.env);
|
|
11
|
+
|
|
12
|
+
const globalForPrisma = globalThis as unknown as {
|
|
13
|
+
prisma: PrismaClient | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
|
17
|
+
datasources: {
|
|
18
|
+
db: {
|
|
19
|
+
url: dbEnv.DATABASE_URL,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
log: process.env.NODE_ENV === 'development'
|
|
23
|
+
? ['query', 'error', 'warn']
|
|
24
|
+
: ['error'],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
28
|
+
globalForPrisma.prisma = prisma;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Test connection
|
|
32
|
+
prisma.$connect()
|
|
33
|
+
.then(() => logger.info('Database connected successfully'))
|
|
34
|
+
.catch((error) => {
|
|
35
|
+
logger.error('Database connection failed:', error);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Graceful shutdown
|
|
40
|
+
process.on('beforeExit', async () => {
|
|
41
|
+
await prisma.$disconnect();
|
|
42
|
+
logger.info('Database disconnected');
|
|
43
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { zValidator } from '@hono/zod-validator';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { UserService } from '../services/user.service.js';
|
|
5
|
+
import { success, created, noContent, paginated } from '../lib/response.js';
|
|
6
|
+
import { CreateUserSchema, UpdateUserSchema } from '__PACKAGE_SCOPE__/shared';
|
|
7
|
+
|
|
8
|
+
const users = new Hono();
|
|
9
|
+
|
|
10
|
+
// Query params schema for pagination
|
|
11
|
+
const paginationSchema = z.object({
|
|
12
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
13
|
+
limit: z.coerce.number().int().min(1).max(100).default(10),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Query params schema for search
|
|
17
|
+
const searchSchema = paginationSchema.extend({
|
|
18
|
+
q: z.string().min(1),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* GET /users - List all users
|
|
23
|
+
*/
|
|
24
|
+
users.get('/', zValidator('query', paginationSchema), async (c) => {
|
|
25
|
+
const { page, limit } = c.req.valid('query');
|
|
26
|
+
const result = await UserService.getAll(page, limit);
|
|
27
|
+
|
|
28
|
+
return paginated(c, result.users, page, limit, result.total);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* GET /users/search - Search users
|
|
33
|
+
*/
|
|
34
|
+
users.get('/search', zValidator('query', searchSchema), async (c) => {
|
|
35
|
+
const { q, page, limit } = c.req.valid('query');
|
|
36
|
+
const result = await UserService.search(q, page, limit);
|
|
37
|
+
|
|
38
|
+
return paginated(c, result.users, page, limit, result.total);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* GET /users/:id - Get user by ID
|
|
43
|
+
*/
|
|
44
|
+
users.get('/:id', async (c) => {
|
|
45
|
+
const id = c.req.param('id');
|
|
46
|
+
const user = await UserService.getById(id);
|
|
47
|
+
|
|
48
|
+
return success(c, user);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* POST /users - Create new user
|
|
53
|
+
*/
|
|
54
|
+
users.post('/', zValidator('json', CreateUserSchema), async (c) => {
|
|
55
|
+
const data = c.req.valid('json');
|
|
56
|
+
const user = await UserService.create(data);
|
|
57
|
+
|
|
58
|
+
return created(c, user);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* PATCH /users/:id - Update user
|
|
63
|
+
*/
|
|
64
|
+
users.patch('/:id', zValidator('json', UpdateUserSchema), async (c) => {
|
|
65
|
+
const id = c.req.param('id');
|
|
66
|
+
const data = c.req.valid('json');
|
|
67
|
+
const user = await UserService.update(id, data);
|
|
68
|
+
|
|
69
|
+
return success(c, user);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* DELETE /users/:id - Delete user
|
|
74
|
+
*/
|
|
75
|
+
users.delete('/:id', async (c) => {
|
|
76
|
+
const id = c.req.param('id');
|
|
77
|
+
await UserService.delete(id);
|
|
78
|
+
|
|
79
|
+
return noContent(c);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export { users };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { prisma } from '../config/database.js';
|
|
2
|
+
import { NotFoundError, ConflictError } from '../lib/errors.js';
|
|
3
|
+
import type { CreateUser, UpdateUser, User } from '__PACKAGE_SCOPE__/shared';
|
|
4
|
+
|
|
5
|
+
export class UserService {
|
|
6
|
+
/**
|
|
7
|
+
* Get all users with pagination
|
|
8
|
+
*/
|
|
9
|
+
static async getAll(page: number = 1, limit: number = 10) {
|
|
10
|
+
const skip = (page - 1) * limit;
|
|
11
|
+
|
|
12
|
+
const [users, total] = await Promise.all([
|
|
13
|
+
prisma.user.findMany({
|
|
14
|
+
skip,
|
|
15
|
+
take: limit,
|
|
16
|
+
orderBy: { createdAt: 'desc' },
|
|
17
|
+
}),
|
|
18
|
+
prisma.user.count(),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
return { users, total, page, limit };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get user by ID
|
|
26
|
+
*/
|
|
27
|
+
static async getById(id: string): Promise<User> {
|
|
28
|
+
const user = await prisma.user.findUnique({
|
|
29
|
+
where: { id },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!user) {
|
|
33
|
+
throw new NotFoundError(`User with ID ${id} not found`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return user;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get user by email
|
|
41
|
+
*/
|
|
42
|
+
static async getByEmail(email: string): Promise<User | null> {
|
|
43
|
+
return prisma.user.findUnique({
|
|
44
|
+
where: { email },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create new user
|
|
50
|
+
*/
|
|
51
|
+
static async create(data: CreateUser): Promise<User> {
|
|
52
|
+
// Check if email already exists
|
|
53
|
+
const existing = await this.getByEmail(data.email);
|
|
54
|
+
if (existing) {
|
|
55
|
+
throw new ConflictError(`User with email ${data.email} already exists`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return prisma.user.create({
|
|
59
|
+
data,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Update user
|
|
65
|
+
*/
|
|
66
|
+
static async update(id: string, data: UpdateUser): Promise<User> {
|
|
67
|
+
// Verify user exists
|
|
68
|
+
await this.getById(id);
|
|
69
|
+
|
|
70
|
+
// If email is being updated, check it's not taken
|
|
71
|
+
if (data.email) {
|
|
72
|
+
const existing = await this.getByEmail(data.email);
|
|
73
|
+
if (existing && existing.id !== id) {
|
|
74
|
+
throw new ConflictError(`Email ${data.email} is already taken`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return prisma.user.update({
|
|
79
|
+
where: { id },
|
|
80
|
+
data,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Delete user
|
|
86
|
+
*/
|
|
87
|
+
static async delete(id: string): Promise<void> {
|
|
88
|
+
// Verify user exists
|
|
89
|
+
await this.getById(id);
|
|
90
|
+
|
|
91
|
+
await prisma.user.delete({
|
|
92
|
+
where: { id },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Search users by name or email
|
|
98
|
+
*/
|
|
99
|
+
static async search(query: string, page: number = 1, limit: number = 10) {
|
|
100
|
+
const skip = (page - 1) * limit;
|
|
101
|
+
|
|
102
|
+
const where = {
|
|
103
|
+
OR: [
|
|
104
|
+
{ name: { contains: query, mode: 'insensitive' as const } },
|
|
105
|
+
{ email: { contains: query, mode: 'insensitive' as const } },
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const [users, total] = await Promise.all([
|
|
110
|
+
prisma.user.findMany({
|
|
111
|
+
where,
|
|
112
|
+
skip,
|
|
113
|
+
take: limit,
|
|
114
|
+
orderBy: { createdAt: 'desc' },
|
|
115
|
+
}),
|
|
116
|
+
prisma.user.count({ where }),
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
return { users, total, page, limit };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@prisma/client": "^6.2.0",
|
|
4
|
+
"@hono/zod-validator": "^0.4.1"
|
|
5
|
+
},
|
|
6
|
+
"devDependencies": {
|
|
7
|
+
"prisma": "^6.2.0"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"prisma:generate": "prisma generate",
|
|
11
|
+
"prisma:migrate": "prisma migrate dev",
|
|
12
|
+
"prisma:studio": "prisma studio",
|
|
13
|
+
"prisma:push": "prisma db push"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
generator client {
|
|
2
|
+
provider = "prisma-client-js"
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
datasource db {
|
|
6
|
+
provider = "postgresql"
|
|
7
|
+
url = env("DATABASE_URL")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
model User {
|
|
11
|
+
id String @id @default(cuid())
|
|
12
|
+
email String @unique
|
|
13
|
+
name String?
|
|
14
|
+
createdAt DateTime @default(now())
|
|
15
|
+
updatedAt DateTime @updatedAt
|
|
16
|
+
|
|
17
|
+
@@map("users")
|
|
18
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { PrismaClient } from '@prisma/client';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
|
|
5
|
+
// Validate database environment variables
|
|
6
|
+
const dbEnvSchema = z.object({
|
|
7
|
+
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const dbEnv = dbEnvSchema.parse(process.env);
|
|
11
|
+
|
|
12
|
+
const globalForPrisma = globalThis as unknown as {
|
|
13
|
+
prisma: PrismaClient | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
|
17
|
+
datasources: {
|
|
18
|
+
db: {
|
|
19
|
+
url: dbEnv.DATABASE_URL,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
log: process.env.NODE_ENV === 'development'
|
|
23
|
+
? ['query', 'error', 'warn']
|
|
24
|
+
: ['error'],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
28
|
+
globalForPrisma.prisma = prisma;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Test connection
|
|
32
|
+
prisma.$connect()
|
|
33
|
+
.then(() => logger.info('Database connected successfully'))
|
|
34
|
+
.catch((error) => {
|
|
35
|
+
logger.error('Database connection failed:', error);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Graceful shutdown
|
|
40
|
+
process.on('beforeExit', async () => {
|
|
41
|
+
await prisma.$disconnect();
|
|
42
|
+
logger.info('Database disconnected');
|
|
43
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { zValidator } from '@hono/zod-validator';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { UserService } from '../services/user.service.js';
|
|
5
|
+
import { success, created, noContent, paginated } from '../lib/response.js';
|
|
6
|
+
import { CreateUserSchema, UpdateUserSchema } from '__PACKAGE_SCOPE__/shared';
|
|
7
|
+
|
|
8
|
+
const users = new Hono();
|
|
9
|
+
|
|
10
|
+
// Query params schema for pagination
|
|
11
|
+
const paginationSchema = z.object({
|
|
12
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
13
|
+
limit: z.coerce.number().int().min(1).max(100).default(10),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Query params schema for search
|
|
17
|
+
const searchSchema = paginationSchema.extend({
|
|
18
|
+
q: z.string().min(1),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* GET /users - List all users
|
|
23
|
+
*/
|
|
24
|
+
users.get('/', zValidator('query', paginationSchema), async (c) => {
|
|
25
|
+
const { page, limit } = c.req.valid('query');
|
|
26
|
+
const result = await UserService.getAll(page, limit);
|
|
27
|
+
|
|
28
|
+
return paginated(c, result.users, page, limit, result.total);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* GET /users/search - Search users
|
|
33
|
+
*/
|
|
34
|
+
users.get('/search', zValidator('query', searchSchema), async (c) => {
|
|
35
|
+
const { q, page, limit } = c.req.valid('query');
|
|
36
|
+
const result = await UserService.search(q, page, limit);
|
|
37
|
+
|
|
38
|
+
return paginated(c, result.users, page, limit, result.total);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* GET /users/:id - Get user by ID
|
|
43
|
+
*/
|
|
44
|
+
users.get('/:id', async (c) => {
|
|
45
|
+
const id = c.req.param('id');
|
|
46
|
+
const user = await UserService.getById(id);
|
|
47
|
+
|
|
48
|
+
return success(c, user);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* POST /users - Create new user
|
|
53
|
+
*/
|
|
54
|
+
users.post('/', zValidator('json', CreateUserSchema), async (c) => {
|
|
55
|
+
const data = c.req.valid('json');
|
|
56
|
+
const user = await UserService.create(data);
|
|
57
|
+
|
|
58
|
+
return created(c, user);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* PATCH /users/:id - Update user
|
|
63
|
+
*/
|
|
64
|
+
users.patch('/:id', zValidator('json', UpdateUserSchema), async (c) => {
|
|
65
|
+
const id = c.req.param('id');
|
|
66
|
+
const data = c.req.valid('json');
|
|
67
|
+
const user = await UserService.update(id, data);
|
|
68
|
+
|
|
69
|
+
return success(c, user);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* DELETE /users/:id - Delete user
|
|
74
|
+
*/
|
|
75
|
+
users.delete('/:id', async (c) => {
|
|
76
|
+
const id = c.req.param('id');
|
|
77
|
+
await UserService.delete(id);
|
|
78
|
+
|
|
79
|
+
return noContent(c);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export { users };
|