create-pxlr 1.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 +160 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +264 -0
- package/package.json +51 -0
- package/templates/blog/frontend/app/blog/[slug]/page.tsx +175 -0
- package/templates/blog/frontend/app/blog/page.tsx +102 -0
- package/templates/blog/frontend/app/components/footer.tsx +21 -0
- package/templates/blog/frontend/app/components/header.tsx +45 -0
- package/templates/blog/frontend/app/globals.css +30 -0
- package/templates/blog/frontend/app/layout.tsx +38 -0
- package/templates/blog/frontend/app/lib/cms.ts +71 -0
- package/templates/blog/frontend/app/page.tsx +155 -0
- package/templates/blog/frontend/next.config.ts +16 -0
- package/templates/blog/frontend/package.json +24 -0
- package/templates/blog/frontend/postcss.config.mjs +7 -0
- package/templates/blog/frontend/tsconfig.json +23 -0
- package/templates/blog/pxlr-cms/README.md +188 -0
- package/templates/blog/pxlr-cms/docker-compose.yml +132 -0
- package/templates/blog/pxlr-cms/nginx/nginx.conf +107 -0
- package/templates/blog/pxlr-cms/packages/admin/.dockerignore +4 -0
- package/templates/blog/pxlr-cms/packages/admin/.env.example +2 -0
- package/templates/blog/pxlr-cms/packages/admin/Dockerfile +19 -0
- package/templates/blog/pxlr-cms/packages/admin/next-env.d.ts +6 -0
- package/templates/blog/pxlr-cms/packages/admin/next.config.ts +22 -0
- package/templates/blog/pxlr-cms/packages/admin/package.json +63 -0
- package/templates/blog/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
- package/templates/blog/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/globals.css +132 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
- package/templates/blog/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
- package/templates/blog/pxlr-cms/packages/admin/tsconfig.json +27 -0
- package/templates/blog/pxlr-cms/packages/api/.env.example +23 -0
- package/templates/blog/pxlr-cms/packages/api/Dockerfile +26 -0
- package/templates/blog/pxlr-cms/packages/api/package.json +42 -0
- package/templates/blog/pxlr-cms/packages/api/src/config.ts +39 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/index.ts +60 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/init.sql +258 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/redis.ts +95 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/seed.sql +78 -0
- package/templates/blog/pxlr-cms/packages/api/src/index.ts +157 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
- package/templates/blog/pxlr-cms/packages/api/tsconfig.json +24 -0
- package/templates/blog/pxlr-cms/packages/shared/package.json +14 -0
- package/templates/blog/pxlr-cms/packages/shared/src/types/index.ts +139 -0
- package/templates/blog/pxlr-cms/packages/shared/tsconfig.json +18 -0
- package/templates/clean/pxlr-cms/README.md +188 -0
- package/templates/clean/pxlr-cms/docker-compose.yml +132 -0
- package/templates/clean/pxlr-cms/nginx/nginx.conf +107 -0
- package/templates/clean/pxlr-cms/packages/admin/.dockerignore +4 -0
- package/templates/clean/pxlr-cms/packages/admin/.env.example +2 -0
- package/templates/clean/pxlr-cms/packages/admin/Dockerfile +19 -0
- package/templates/clean/pxlr-cms/packages/admin/next-env.d.ts +6 -0
- package/templates/clean/pxlr-cms/packages/admin/next.config.ts +22 -0
- package/templates/clean/pxlr-cms/packages/admin/package.json +63 -0
- package/templates/clean/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
- package/templates/clean/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/globals.css +132 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
- package/templates/clean/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
- package/templates/clean/pxlr-cms/packages/admin/tsconfig.json +27 -0
- package/templates/clean/pxlr-cms/packages/api/.env.example +23 -0
- package/templates/clean/pxlr-cms/packages/api/Dockerfile +26 -0
- package/templates/clean/pxlr-cms/packages/api/package.json +42 -0
- package/templates/clean/pxlr-cms/packages/api/src/config.ts +39 -0
- package/templates/clean/pxlr-cms/packages/api/src/database/index.ts +60 -0
- package/templates/clean/pxlr-cms/packages/api/src/database/init.sql +178 -0
- package/templates/clean/pxlr-cms/packages/api/src/database/redis.ts +95 -0
- package/templates/clean/pxlr-cms/packages/api/src/index.ts +157 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
- package/templates/clean/pxlr-cms/packages/api/tsconfig.json +24 -0
- package/templates/clean/pxlr-cms/packages/shared/package.json +14 -0
- package/templates/clean/pxlr-cms/packages/shared/src/types/index.ts +139 -0
- package/templates/clean/pxlr-cms/packages/shared/tsconfig.json +18 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import bcrypt from 'bcryptjs';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { db } from '../../database/index.js';
|
|
5
|
+
|
|
6
|
+
const loginSchema = z.object({
|
|
7
|
+
email: z.string().email(),
|
|
8
|
+
password: z.string().min(6),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const registerSchema = z.object({
|
|
12
|
+
email: z.string().email(),
|
|
13
|
+
password: z.string().min(6),
|
|
14
|
+
name: z.string().min(2),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const authRoutes: FastifyPluginAsync = async (fastify) => {
|
|
18
|
+
// Login
|
|
19
|
+
fastify.post('/login', {
|
|
20
|
+
schema: {
|
|
21
|
+
tags: ['Auth'],
|
|
22
|
+
summary: 'Login to PXLR CMS',
|
|
23
|
+
body: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
required: ['email', 'password'],
|
|
26
|
+
properties: {
|
|
27
|
+
email: { type: 'string', format: 'email' },
|
|
28
|
+
password: { type: 'string', minLength: 6 },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
response: {
|
|
32
|
+
200: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
token: { type: 'string' },
|
|
36
|
+
user: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
id: { type: 'string' },
|
|
40
|
+
email: { type: 'string' },
|
|
41
|
+
name: { type: 'string' },
|
|
42
|
+
role: { type: 'string' },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}, async (request, reply) => {
|
|
50
|
+
const body = loginSchema.parse(request.body);
|
|
51
|
+
|
|
52
|
+
const result = await db.query(
|
|
53
|
+
`SELECT id, email, password_hash, name, role, is_active
|
|
54
|
+
FROM users WHERE email = $1`,
|
|
55
|
+
[body.email]
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const user = result.rows[0];
|
|
59
|
+
|
|
60
|
+
if (!user || !user.is_active) {
|
|
61
|
+
return reply.status(401).send({ error: true, message: 'Invalid credentials' });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const validPassword = await bcrypt.compare(body.password, user.password_hash);
|
|
65
|
+
if (!validPassword) {
|
|
66
|
+
return reply.status(401).send({ error: true, message: 'Invalid credentials' });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Update last login
|
|
70
|
+
await db.query(
|
|
71
|
+
'UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = $1',
|
|
72
|
+
[user.id]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const token = fastify.jwt.sign({
|
|
76
|
+
id: user.id,
|
|
77
|
+
email: user.email,
|
|
78
|
+
role: user.role,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
token,
|
|
83
|
+
user: {
|
|
84
|
+
id: user.id,
|
|
85
|
+
email: user.email,
|
|
86
|
+
name: user.name,
|
|
87
|
+
role: user.role,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Register (admin only in production)
|
|
93
|
+
fastify.post('/register', {
|
|
94
|
+
schema: {
|
|
95
|
+
tags: ['Auth'],
|
|
96
|
+
summary: 'Register a new user',
|
|
97
|
+
body: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
required: ['email', 'password', 'name'],
|
|
100
|
+
properties: {
|
|
101
|
+
email: { type: 'string', format: 'email' },
|
|
102
|
+
password: { type: 'string', minLength: 6 },
|
|
103
|
+
name: { type: 'string', minLength: 2 },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
}, async (request, reply) => {
|
|
108
|
+
const body = registerSchema.parse(request.body);
|
|
109
|
+
|
|
110
|
+
// Check if user exists
|
|
111
|
+
const existing = await db.query(
|
|
112
|
+
'SELECT id FROM users WHERE email = $1',
|
|
113
|
+
[body.email]
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (existing.rows.length > 0) {
|
|
117
|
+
return reply.status(400).send({ error: true, message: 'Email already registered' });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const passwordHash = await bcrypt.hash(body.password, 12);
|
|
121
|
+
|
|
122
|
+
const result = await db.query(
|
|
123
|
+
`INSERT INTO users (email, password_hash, name, role)
|
|
124
|
+
VALUES ($1, $2, $3, 'editor')
|
|
125
|
+
RETURNING id, email, name, role`,
|
|
126
|
+
[body.email, passwordHash, body.name]
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const user = result.rows[0];
|
|
130
|
+
|
|
131
|
+
const token = fastify.jwt.sign({
|
|
132
|
+
id: user.id,
|
|
133
|
+
email: user.email,
|
|
134
|
+
role: user.role,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
token,
|
|
139
|
+
user: {
|
|
140
|
+
id: user.id,
|
|
141
|
+
email: user.email,
|
|
142
|
+
name: user.name,
|
|
143
|
+
role: user.role,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Get current user
|
|
149
|
+
fastify.get('/me', {
|
|
150
|
+
schema: {
|
|
151
|
+
tags: ['Auth'],
|
|
152
|
+
summary: 'Get current user',
|
|
153
|
+
security: [{ bearerAuth: [] }],
|
|
154
|
+
},
|
|
155
|
+
preHandler: [fastify.authenticate],
|
|
156
|
+
}, async (request) => {
|
|
157
|
+
const user = request.user as { id: string };
|
|
158
|
+
|
|
159
|
+
const result = await db.query(
|
|
160
|
+
`SELECT id, email, name, role, avatar_url, created_at
|
|
161
|
+
FROM users WHERE id = $1`,
|
|
162
|
+
[user.id]
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return { user: result.rows[0] };
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Logout (invalidate token - for future implementation with token blacklist)
|
|
169
|
+
fastify.post('/logout', {
|
|
170
|
+
schema: {
|
|
171
|
+
tags: ['Auth'],
|
|
172
|
+
summary: 'Logout current user',
|
|
173
|
+
security: [{ bearerAuth: [] }],
|
|
174
|
+
},
|
|
175
|
+
preHandler: [fastify.authenticate],
|
|
176
|
+
}, async () => {
|
|
177
|
+
return { success: true, message: 'Logged out successfully' };
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Update profile
|
|
181
|
+
fastify.put('/profile', {
|
|
182
|
+
schema: {
|
|
183
|
+
tags: ['Auth'],
|
|
184
|
+
summary: 'Update user profile',
|
|
185
|
+
security: [{ bearerAuth: [] }],
|
|
186
|
+
},
|
|
187
|
+
preHandler: [fastify.authenticate],
|
|
188
|
+
}, async (request, reply) => {
|
|
189
|
+
const user = request.user as { id: string };
|
|
190
|
+
const body = z.object({
|
|
191
|
+
name: z.string().min(2),
|
|
192
|
+
}).parse(request.body);
|
|
193
|
+
|
|
194
|
+
const result = await db.query(
|
|
195
|
+
`UPDATE users SET name = $1, updated_at = CURRENT_TIMESTAMP
|
|
196
|
+
WHERE id = $2
|
|
197
|
+
RETURNING id, email, name, role, avatar_url`,
|
|
198
|
+
[body.name, user.id]
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (result.rows.length === 0) {
|
|
202
|
+
return reply.status(404).send({ error: true, message: 'User not found' });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { user: result.rows[0] };
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Change password
|
|
209
|
+
fastify.put('/password', {
|
|
210
|
+
schema: {
|
|
211
|
+
tags: ['Auth'],
|
|
212
|
+
summary: 'Change user password',
|
|
213
|
+
security: [{ bearerAuth: [] }],
|
|
214
|
+
},
|
|
215
|
+
preHandler: [fastify.authenticate],
|
|
216
|
+
}, async (request, reply) => {
|
|
217
|
+
const user = request.user as { id: string };
|
|
218
|
+
const body = z.object({
|
|
219
|
+
currentPassword: z.string().min(6),
|
|
220
|
+
newPassword: z.string().min(6),
|
|
221
|
+
}).parse(request.body);
|
|
222
|
+
|
|
223
|
+
// Get current password hash
|
|
224
|
+
const result = await db.query(
|
|
225
|
+
'SELECT password_hash FROM users WHERE id = $1',
|
|
226
|
+
[user.id]
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (result.rows.length === 0) {
|
|
230
|
+
return reply.status(404).send({ error: true, message: 'User not found' });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Verify current password
|
|
234
|
+
const validPassword = await bcrypt.compare(body.currentPassword, result.rows[0].password_hash);
|
|
235
|
+
if (!validPassword) {
|
|
236
|
+
return reply.status(400).send({ error: true, message: 'Current password is incorrect' });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Hash new password and update
|
|
240
|
+
const newPasswordHash = await bcrypt.hash(body.newPassword, 12);
|
|
241
|
+
await db.query(
|
|
242
|
+
'UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
|
243
|
+
[newPasswordHash, user.id]
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return { success: true, message: 'Password changed successfully' };
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Type augmentation for Fastify
|
|
252
|
+
declare module 'fastify' {
|
|
253
|
+
interface FastifyInstance {
|
|
254
|
+
authenticate: any;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { v4 as uuid } from 'uuid';
|
|
4
|
+
import { db } from '../../database/index.js';
|
|
5
|
+
import { redis } from '../../database/redis.js';
|
|
6
|
+
|
|
7
|
+
const createDocumentSchema = z.object({
|
|
8
|
+
schemaName: z.string().min(1),
|
|
9
|
+
data: z.record(z.any()),
|
|
10
|
+
locale: z.string().default('en'),
|
|
11
|
+
status: z.enum(['draft', 'published']).default('draft'),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const updateDocumentSchema = z.object({
|
|
15
|
+
data: z.record(z.any()).optional(),
|
|
16
|
+
locale: z.string().optional(),
|
|
17
|
+
status: z.enum(['draft', 'published', 'archived']).optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const querySchema = z.object({
|
|
21
|
+
schemaName: z.string().optional(),
|
|
22
|
+
status: z.enum(['draft', 'published', 'archived']).optional(),
|
|
23
|
+
locale: z.string().optional(),
|
|
24
|
+
page: z.coerce.number().min(1).default(1),
|
|
25
|
+
limit: z.coerce.number().min(1).max(100).default(20),
|
|
26
|
+
orderBy: z.string().default('created_at'),
|
|
27
|
+
order: z.enum(['asc', 'desc']).default('desc'),
|
|
28
|
+
search: z.string().optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const contentRoutes: FastifyPluginAsync = async (fastify) => {
|
|
32
|
+
// List documents with filtering
|
|
33
|
+
fastify.get('/', {
|
|
34
|
+
schema: {
|
|
35
|
+
tags: ['Content'],
|
|
36
|
+
summary: 'List documents with filtering and pagination',
|
|
37
|
+
querystring: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
schemaName: { type: 'string' },
|
|
41
|
+
status: { type: 'string' },
|
|
42
|
+
locale: { type: 'string' },
|
|
43
|
+
page: { type: 'number' },
|
|
44
|
+
limit: { type: 'number' },
|
|
45
|
+
orderBy: { type: 'string' },
|
|
46
|
+
order: { type: 'string' },
|
|
47
|
+
search: { type: 'string' },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}, async (request) => {
|
|
52
|
+
const query = querySchema.parse(request.query);
|
|
53
|
+
const offset = (query.page - 1) * query.limit;
|
|
54
|
+
|
|
55
|
+
let whereClause = 'WHERE 1=1';
|
|
56
|
+
const params: any[] = [];
|
|
57
|
+
let paramIndex = 1;
|
|
58
|
+
|
|
59
|
+
if (query.schemaName) {
|
|
60
|
+
whereClause += ` AND schema_name = $${paramIndex++}`;
|
|
61
|
+
params.push(query.schemaName);
|
|
62
|
+
}
|
|
63
|
+
if (query.status) {
|
|
64
|
+
whereClause += ` AND status = $${paramIndex++}`;
|
|
65
|
+
params.push(query.status);
|
|
66
|
+
}
|
|
67
|
+
if (query.locale) {
|
|
68
|
+
whereClause += ` AND locale = $${paramIndex++}`;
|
|
69
|
+
params.push(query.locale);
|
|
70
|
+
}
|
|
71
|
+
if (query.search) {
|
|
72
|
+
whereClause += ` AND data::text ILIKE $${paramIndex++}`;
|
|
73
|
+
params.push(`%${query.search}%`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Count total
|
|
77
|
+
const countResult = await db.query(
|
|
78
|
+
`SELECT COUNT(*) FROM documents ${whereClause}`,
|
|
79
|
+
params
|
|
80
|
+
);
|
|
81
|
+
const total = parseInt(countResult.rows[0].count);
|
|
82
|
+
|
|
83
|
+
// Get documents
|
|
84
|
+
const allowedOrderBy = ['created_at', 'updated_at', 'published_at'];
|
|
85
|
+
const orderByColumn = allowedOrderBy.includes(query.orderBy) ? query.orderBy : 'created_at';
|
|
86
|
+
const orderDirection = query.order === 'asc' ? 'ASC' : 'DESC';
|
|
87
|
+
|
|
88
|
+
params.push(query.limit, offset);
|
|
89
|
+
|
|
90
|
+
const result = await db.query(
|
|
91
|
+
`SELECT d.*, s.title as schema_title, u.name as created_by_name
|
|
92
|
+
FROM documents d
|
|
93
|
+
LEFT JOIN schemas s ON d.schema_name = s.name
|
|
94
|
+
LEFT JOIN users u ON d.created_by = u.id
|
|
95
|
+
${whereClause}
|
|
96
|
+
ORDER BY d.${orderByColumn} ${orderDirection}
|
|
97
|
+
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
|
98
|
+
params
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
documents: result.rows,
|
|
103
|
+
pagination: {
|
|
104
|
+
page: query.page,
|
|
105
|
+
limit: query.limit,
|
|
106
|
+
total,
|
|
107
|
+
totalPages: Math.ceil(total / query.limit),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Get single document
|
|
113
|
+
fastify.get('/:id', {
|
|
114
|
+
schema: {
|
|
115
|
+
tags: ['Content'],
|
|
116
|
+
summary: 'Get document by ID',
|
|
117
|
+
},
|
|
118
|
+
}, async (request, reply) => {
|
|
119
|
+
const { id } = request.params as { id: string };
|
|
120
|
+
|
|
121
|
+
const result = await db.query(
|
|
122
|
+
`SELECT d.*, s.title as schema_title, s.definition as schema_definition,
|
|
123
|
+
u1.name as created_by_name, u2.name as updated_by_name
|
|
124
|
+
FROM documents d
|
|
125
|
+
LEFT JOIN schemas s ON d.schema_name = s.name
|
|
126
|
+
LEFT JOIN users u1 ON d.created_by = u1.id
|
|
127
|
+
LEFT JOIN users u2 ON d.updated_by = u2.id
|
|
128
|
+
WHERE d.id = $1`,
|
|
129
|
+
[id]
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (result.rows.length === 0) {
|
|
133
|
+
return reply.status(404).send({ error: true, message: 'Document not found' });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { document: result.rows[0] };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Create document
|
|
140
|
+
fastify.post('/', {
|
|
141
|
+
schema: {
|
|
142
|
+
tags: ['Content'],
|
|
143
|
+
summary: 'Create a new document',
|
|
144
|
+
security: [{ bearerAuth: [] }],
|
|
145
|
+
},
|
|
146
|
+
preHandler: [fastify.authenticate],
|
|
147
|
+
}, async (request, reply) => {
|
|
148
|
+
const body = createDocumentSchema.parse(request.body);
|
|
149
|
+
const user = request.user as { id: string };
|
|
150
|
+
|
|
151
|
+
// Verify schema exists
|
|
152
|
+
const schemaResult = await db.query(
|
|
153
|
+
'SELECT id, is_singleton FROM schemas WHERE name = $1',
|
|
154
|
+
[body.schemaName]
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
if (schemaResult.rows.length === 0) {
|
|
158
|
+
return reply.status(400).send({ error: true, message: 'Schema not found' });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const schema = schemaResult.rows[0];
|
|
162
|
+
|
|
163
|
+
// Check singleton
|
|
164
|
+
if (schema.is_singleton) {
|
|
165
|
+
const existingDoc = await db.query(
|
|
166
|
+
'SELECT id FROM documents WHERE schema_name = $1 AND locale = $2',
|
|
167
|
+
[body.schemaName, body.locale]
|
|
168
|
+
);
|
|
169
|
+
if (existingDoc.rows.length > 0) {
|
|
170
|
+
return reply.status(400).send({
|
|
171
|
+
error: true,
|
|
172
|
+
message: 'Singleton document already exists for this locale'
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const documentId = uuid();
|
|
178
|
+
const publishedAt = body.status === 'published' ? new Date() : null;
|
|
179
|
+
|
|
180
|
+
const result = await db.query(
|
|
181
|
+
`INSERT INTO documents (id, schema_name, data, locale, status, published_at, created_by, updated_by)
|
|
182
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
|
183
|
+
RETURNING *`,
|
|
184
|
+
[documentId, body.schemaName, body.data, body.locale, body.status, publishedAt, user.id]
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Create initial version
|
|
188
|
+
await db.query(
|
|
189
|
+
`INSERT INTO document_versions (document_id, version, data, locale, created_by)
|
|
190
|
+
VALUES ($1, 1, $2, $3, $4)`,
|
|
191
|
+
[documentId, body.data, body.locale, user.id]
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Publish event for real-time
|
|
195
|
+
await redis.publish('content:created', {
|
|
196
|
+
documentId,
|
|
197
|
+
schemaName: body.schemaName,
|
|
198
|
+
userId: user.id,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return { document: result.rows[0] };
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Update document
|
|
205
|
+
fastify.put('/:id', {
|
|
206
|
+
schema: {
|
|
207
|
+
tags: ['Content'],
|
|
208
|
+
summary: 'Update a document',
|
|
209
|
+
security: [{ bearerAuth: [] }],
|
|
210
|
+
},
|
|
211
|
+
preHandler: [fastify.authenticate],
|
|
212
|
+
}, async (request, reply) => {
|
|
213
|
+
const { id } = request.params as { id: string };
|
|
214
|
+
const body = updateDocumentSchema.parse(request.body);
|
|
215
|
+
const user = request.user as { id: string };
|
|
216
|
+
|
|
217
|
+
// Get current document
|
|
218
|
+
const currentDoc = await db.query(
|
|
219
|
+
'SELECT * FROM documents WHERE id = $1',
|
|
220
|
+
[id]
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
if (currentDoc.rows.length === 0) {
|
|
224
|
+
return reply.status(404).send({ error: true, message: 'Document not found' });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const doc = currentDoc.rows[0];
|
|
228
|
+
|
|
229
|
+
// Build update query
|
|
230
|
+
const updates: string[] = ['updated_by = $1'];
|
|
231
|
+
const values: any[] = [user.id];
|
|
232
|
+
let paramIndex = 2;
|
|
233
|
+
|
|
234
|
+
if (body.data) {
|
|
235
|
+
updates.push(`data = $${paramIndex++}`);
|
|
236
|
+
values.push(body.data);
|
|
237
|
+
}
|
|
238
|
+
if (body.locale) {
|
|
239
|
+
updates.push(`locale = $${paramIndex++}`);
|
|
240
|
+
values.push(body.locale);
|
|
241
|
+
}
|
|
242
|
+
if (body.status) {
|
|
243
|
+
updates.push(`status = $${paramIndex++}`);
|
|
244
|
+
values.push(body.status);
|
|
245
|
+
if (body.status === 'published' && doc.status !== 'published') {
|
|
246
|
+
updates.push(`published_at = CURRENT_TIMESTAMP`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
values.push(id);
|
|
251
|
+
|
|
252
|
+
const result = await db.query(
|
|
253
|
+
`UPDATE documents SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
|
254
|
+
values
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Create new version if data changed
|
|
258
|
+
if (body.data) {
|
|
259
|
+
const versionResult = await db.query(
|
|
260
|
+
'SELECT MAX(version) as max_version FROM document_versions WHERE document_id = $1',
|
|
261
|
+
[id]
|
|
262
|
+
);
|
|
263
|
+
const newVersion = (versionResult.rows[0].max_version || 0) + 1;
|
|
264
|
+
|
|
265
|
+
await db.query(
|
|
266
|
+
`INSERT INTO document_versions (document_id, version, data, locale, created_by)
|
|
267
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
268
|
+
[id, newVersion, body.data, body.locale || doc.locale, user.id]
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Publish event for real-time
|
|
273
|
+
await redis.publish('content:updated', {
|
|
274
|
+
documentId: id,
|
|
275
|
+
schemaName: doc.schema_name,
|
|
276
|
+
userId: user.id,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return { document: result.rows[0] };
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Delete document
|
|
283
|
+
fastify.delete('/:id', {
|
|
284
|
+
schema: {
|
|
285
|
+
tags: ['Content'],
|
|
286
|
+
summary: 'Delete a document',
|
|
287
|
+
security: [{ bearerAuth: [] }],
|
|
288
|
+
},
|
|
289
|
+
preHandler: [fastify.authenticate],
|
|
290
|
+
}, async (request, reply) => {
|
|
291
|
+
const { id } = request.params as { id: string };
|
|
292
|
+
const user = request.user as { id: string };
|
|
293
|
+
|
|
294
|
+
const doc = await db.query(
|
|
295
|
+
'SELECT schema_name FROM documents WHERE id = $1',
|
|
296
|
+
[id]
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (doc.rows.length === 0) {
|
|
300
|
+
return reply.status(404).send({ error: true, message: 'Document not found' });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
await db.query('DELETE FROM documents WHERE id = $1', [id]);
|
|
304
|
+
|
|
305
|
+
// Publish event for real-time
|
|
306
|
+
await redis.publish('content:deleted', {
|
|
307
|
+
documentId: id,
|
|
308
|
+
schemaName: doc.rows[0].schema_name,
|
|
309
|
+
userId: user.id,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return { success: true, message: 'Document deleted' };
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Get document versions
|
|
316
|
+
fastify.get('/:id/versions', {
|
|
317
|
+
schema: {
|
|
318
|
+
tags: ['Content'],
|
|
319
|
+
summary: 'Get document version history',
|
|
320
|
+
},
|
|
321
|
+
}, async (request, reply) => {
|
|
322
|
+
const { id } = request.params as { id: string };
|
|
323
|
+
|
|
324
|
+
const doc = await db.query('SELECT id FROM documents WHERE id = $1', [id]);
|
|
325
|
+
if (doc.rows.length === 0) {
|
|
326
|
+
return reply.status(404).send({ error: true, message: 'Document not found' });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const result = await db.query(
|
|
330
|
+
`SELECT v.*, u.name as created_by_name
|
|
331
|
+
FROM document_versions v
|
|
332
|
+
LEFT JOIN users u ON v.created_by = u.id
|
|
333
|
+
WHERE v.document_id = $1
|
|
334
|
+
ORDER BY v.version DESC`,
|
|
335
|
+
[id]
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
return { versions: result.rows };
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Restore document version
|
|
342
|
+
fastify.post('/:id/versions/:version/restore', {
|
|
343
|
+
schema: {
|
|
344
|
+
tags: ['Content'],
|
|
345
|
+
summary: 'Restore document to a specific version',
|
|
346
|
+
security: [{ bearerAuth: [] }],
|
|
347
|
+
},
|
|
348
|
+
preHandler: [fastify.authenticate],
|
|
349
|
+
}, async (request, reply) => {
|
|
350
|
+
const { id, version } = request.params as { id: string; version: string };
|
|
351
|
+
const user = request.user as { id: string };
|
|
352
|
+
|
|
353
|
+
const versionResult = await db.query(
|
|
354
|
+
'SELECT * FROM document_versions WHERE document_id = $1 AND version = $2',
|
|
355
|
+
[id, parseInt(version)]
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
if (versionResult.rows.length === 0) {
|
|
359
|
+
return reply.status(404).send({ error: true, message: 'Version not found' });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const versionData = versionResult.rows[0];
|
|
363
|
+
|
|
364
|
+
// Update document with version data
|
|
365
|
+
const result = await db.query(
|
|
366
|
+
`UPDATE documents SET data = $1, updated_by = $2 WHERE id = $3 RETURNING *`,
|
|
367
|
+
[versionData.data, user.id, id]
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// Create new version for the restore action
|
|
371
|
+
const maxVersionResult = await db.query(
|
|
372
|
+
'SELECT MAX(version) as max_version FROM document_versions WHERE document_id = $1',
|
|
373
|
+
[id]
|
|
374
|
+
);
|
|
375
|
+
const newVersion = (maxVersionResult.rows[0].max_version || 0) + 1;
|
|
376
|
+
|
|
377
|
+
await db.query(
|
|
378
|
+
`INSERT INTO document_versions (document_id, version, data, locale, change_summary, created_by)
|
|
379
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
380
|
+
[id, newVersion, versionData.data, versionData.locale, `Restored to version ${version}`, user.id]
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
return { document: result.rows[0] };
|
|
384
|
+
});
|
|
385
|
+
};
|