@goscribe/server 1.1.7 → 1.3.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/.env.example +43 -0
- package/check-difficulty.cjs +14 -0
- package/check-questions.cjs +14 -0
- package/db-summary.cjs +22 -0
- package/dist/routers/auth.js +1 -1
- package/mcq-test.cjs +36 -0
- package/package.json +10 -2
- package/prisma/migrations/20260413143206_init/migration.sql +873 -0
- package/prisma/schema.prisma +485 -292
- package/src/context.ts +4 -1
- package/src/lib/activity_human_description.test.ts +28 -0
- package/src/lib/activity_human_description.ts +239 -0
- package/src/lib/activity_log_service.test.ts +37 -0
- package/src/lib/activity_log_service.ts +353 -0
- package/src/lib/ai-session.ts +194 -112
- package/src/lib/constants.ts +14 -0
- package/src/lib/email.ts +230 -0
- package/src/lib/env.ts +23 -6
- package/src/lib/inference.ts +3 -3
- package/src/lib/logger.ts +26 -9
- package/src/lib/notification-service.test.ts +106 -0
- package/src/lib/notification-service.ts +677 -0
- package/src/lib/prisma.ts +6 -1
- package/src/lib/pusher.ts +90 -6
- package/src/lib/retry.ts +61 -0
- package/src/lib/storage.ts +2 -2
- package/src/lib/stripe.ts +39 -0
- package/src/lib/subscription_service.ts +722 -0
- package/src/lib/usage_service.ts +74 -0
- package/src/lib/worksheet-generation.test.ts +31 -0
- package/src/lib/worksheet-generation.ts +139 -0
- package/src/lib/workspace-access.ts +13 -0
- package/src/routers/_app.ts +11 -0
- package/src/routers/admin.ts +710 -0
- package/src/routers/annotations.ts +227 -0
- package/src/routers/auth.ts +432 -33
- package/src/routers/copilot.ts +719 -0
- package/src/routers/flashcards.ts +207 -80
- package/src/routers/members.ts +280 -80
- package/src/routers/notifications.ts +142 -0
- package/src/routers/payment.ts +448 -0
- package/src/routers/podcast.ts +133 -108
- package/src/routers/studyguide.ts +80 -74
- package/src/routers/worksheets.ts +300 -80
- package/src/routers/workspace.ts +538 -328
- package/src/scripts/purge-deleted-users.ts +167 -0
- package/src/server.ts +140 -12
- package/src/services/flashcard-progress.service.ts +52 -43
- package/src/trpc.ts +184 -5
- package/test-generate.js +30 -0
- package/test-ratio.cjs +9 -0
- package/zod-test.cjs +22 -0
- package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
- package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
- package/prisma/seed.mjs +0 -135
- package/src/routers/meetingsummary.ts +0 -416
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { router, adminProcedure } from '../trpc.js';
|
|
3
|
+
import { logger } from '../lib/logger.js';
|
|
4
|
+
import { stripe } from '../lib/stripe.js';
|
|
5
|
+
import { ActivityLogCategory, ActivityLogStatus, ArtifactType, InvoiceType } from '@prisma/client';
|
|
6
|
+
import {
|
|
7
|
+
buildActivityLogWhere,
|
|
8
|
+
deleteActivityLogsOlderThan,
|
|
9
|
+
getActivityRetentionDays,
|
|
10
|
+
} from '../lib/activity_log_service.js';
|
|
11
|
+
import { getActivityHumanDescription } from '../lib/activity_human_description.js';
|
|
12
|
+
|
|
13
|
+
function csvEscapeCell(value: string | number | null | undefined): string {
|
|
14
|
+
if (value === null || value === undefined) return '';
|
|
15
|
+
const s = String(value);
|
|
16
|
+
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
|
17
|
+
return s;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const activityLogFiltersInput = z.object({
|
|
21
|
+
from: z.coerce.date().optional(),
|
|
22
|
+
to: z.coerce.date().optional(),
|
|
23
|
+
actorUserId: z.string().optional(),
|
|
24
|
+
workspaceId: z.string().optional(),
|
|
25
|
+
category: z.nativeEnum(ActivityLogCategory).optional(),
|
|
26
|
+
status: z.nativeEnum(ActivityLogStatus).optional(),
|
|
27
|
+
search: z.string().max(200).optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const activityListInput = activityLogFiltersInput.extend({
|
|
31
|
+
page: z.number().int().min(1).default(1),
|
|
32
|
+
limit: z.number().min(1).max(100).default(20),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const listUsersInput = z.object({
|
|
36
|
+
page: z.number().int().min(1).default(1),
|
|
37
|
+
pageSize: z.number().int().min(1).max(100).default(10),
|
|
38
|
+
/** Trims; matches name, email, or id (substring). */
|
|
39
|
+
search: z.string().max(200).optional(),
|
|
40
|
+
emailVerified: z.enum(['all', 'yes', 'no']).default('all'),
|
|
41
|
+
/** Filter by account creation time (joined date). */
|
|
42
|
+
joinedFrom: z.coerce.date().optional(),
|
|
43
|
+
joinedTo: z.coerce.date().optional(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const listInvoicesInput = z.object({
|
|
47
|
+
page: z.number().int().min(1).default(1),
|
|
48
|
+
pageSize: z.number().int().min(1).max(100).default(10),
|
|
49
|
+
search: z.string().max(200).optional(),
|
|
50
|
+
status: z.string().max(32).optional(),
|
|
51
|
+
type: z.nativeEnum(InvoiceType).optional(),
|
|
52
|
+
userId: z.string().optional(),
|
|
53
|
+
from: z.coerce.date().optional(),
|
|
54
|
+
to: z.coerce.date().optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const admin = router({
|
|
58
|
+
getSystemStats: adminProcedure
|
|
59
|
+
.query(async ({ ctx }) => {
|
|
60
|
+
const totalUsers = await ctx.db.user.count();
|
|
61
|
+
const totalWorkspaces = await ctx.db.workspace.count();
|
|
62
|
+
const totalSubscriptions = await ctx.db.subscription.count({
|
|
63
|
+
where: { status: 'active' }
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Calculate revenue breakdown
|
|
67
|
+
const subRevenueResult = await (ctx.db as any).invoice.aggregate({
|
|
68
|
+
where: { status: 'paid', type: 'SUBSCRIPTION' },
|
|
69
|
+
_sum: { amountPaid: true }
|
|
70
|
+
});
|
|
71
|
+
const topupRevenueResult = await (ctx.db as any).invoice.aggregate({
|
|
72
|
+
where: { status: 'paid', type: 'TOPUP' },
|
|
73
|
+
_sum: { amountPaid: true }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const subRevenue = Number(subRevenueResult._sum.amountPaid || 0) / 100;
|
|
77
|
+
const topupRevenue = Number(topupRevenueResult._sum.amountPaid || 0) / 100;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
totalUsers,
|
|
81
|
+
totalWorkspaces,
|
|
82
|
+
totalSubscriptions,
|
|
83
|
+
revenue: subRevenue + topupRevenue,
|
|
84
|
+
subscriptionRevenue: subRevenue,
|
|
85
|
+
topupRevenue: topupRevenue,
|
|
86
|
+
};
|
|
87
|
+
}),
|
|
88
|
+
|
|
89
|
+
listUsers: adminProcedure
|
|
90
|
+
.input(listUsersInput)
|
|
91
|
+
.query(async ({ ctx, input }) => {
|
|
92
|
+
const search = input.search?.trim();
|
|
93
|
+
const baseRoleWhere = {
|
|
94
|
+
role: {
|
|
95
|
+
name: { not: 'System Admin' },
|
|
96
|
+
},
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
const searchWhere =
|
|
100
|
+
search && search.length > 0
|
|
101
|
+
? {
|
|
102
|
+
OR: [
|
|
103
|
+
{ email: { contains: search, mode: 'insensitive' as const } },
|
|
104
|
+
{ name: { contains: search, mode: 'insensitive' as const } },
|
|
105
|
+
{ id: { contains: search } },
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
: undefined;
|
|
109
|
+
|
|
110
|
+
const verifiedWhere =
|
|
111
|
+
input.emailVerified === 'yes'
|
|
112
|
+
? { emailVerified: { not: null } }
|
|
113
|
+
: input.emailVerified === 'no'
|
|
114
|
+
? { emailVerified: null }
|
|
115
|
+
: undefined;
|
|
116
|
+
|
|
117
|
+
const joinedWhere =
|
|
118
|
+
input.joinedFrom || input.joinedTo
|
|
119
|
+
? {
|
|
120
|
+
createdAt: {
|
|
121
|
+
...(input.joinedFrom ? { gte: input.joinedFrom } : {}),
|
|
122
|
+
...(input.joinedTo ? { lte: input.joinedTo } : {}),
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
: undefined;
|
|
126
|
+
|
|
127
|
+
const where = {
|
|
128
|
+
AND: [
|
|
129
|
+
baseRoleWhere,
|
|
130
|
+
...(searchWhere ? [searchWhere] : []),
|
|
131
|
+
...(verifiedWhere ? [verifiedWhere] : []),
|
|
132
|
+
...(joinedWhere ? [joinedWhere] : []),
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const totalCount = await ctx.db.user.count({ where });
|
|
137
|
+
const pageSize = input.pageSize;
|
|
138
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
139
|
+
const page = Math.min(Math.max(1, input.page), totalPages);
|
|
140
|
+
const skip = (page - 1) * pageSize;
|
|
141
|
+
|
|
142
|
+
const users = await ctx.db.user.findMany({
|
|
143
|
+
where,
|
|
144
|
+
skip,
|
|
145
|
+
take: pageSize,
|
|
146
|
+
orderBy: { createdAt: 'desc' },
|
|
147
|
+
include: {
|
|
148
|
+
role: true,
|
|
149
|
+
profilePicture: true,
|
|
150
|
+
subscriptions: {
|
|
151
|
+
orderBy: { createdAt: 'desc' },
|
|
152
|
+
take: 1,
|
|
153
|
+
include: {
|
|
154
|
+
plan: true,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
users: users.map((user: any) => ({
|
|
162
|
+
...user,
|
|
163
|
+
profilePicture: user.profilePicture?.objectKey
|
|
164
|
+
? `/profile-picture/${user.profilePicture.objectKey}?t=${new Date(user.updatedAt).getTime()}`
|
|
165
|
+
: null,
|
|
166
|
+
})),
|
|
167
|
+
page,
|
|
168
|
+
pageSize,
|
|
169
|
+
totalCount,
|
|
170
|
+
totalPages,
|
|
171
|
+
};
|
|
172
|
+
}),
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Paginated global invoice list for admin Invoices page (search + filters).
|
|
176
|
+
*/
|
|
177
|
+
listInvoices: adminProcedure
|
|
178
|
+
.input(listInvoicesInput)
|
|
179
|
+
.query(async ({ ctx, input }) => {
|
|
180
|
+
const search = input.search?.trim();
|
|
181
|
+
|
|
182
|
+
const searchWhere =
|
|
183
|
+
search && search.length > 0
|
|
184
|
+
? {
|
|
185
|
+
OR: [
|
|
186
|
+
{ id: { contains: search } },
|
|
187
|
+
{ stripeInvoiceId: { contains: search, mode: 'insensitive' as const } },
|
|
188
|
+
{
|
|
189
|
+
user: {
|
|
190
|
+
email: { contains: search, mode: 'insensitive' as const },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
user: {
|
|
195
|
+
name: { contains: search, mode: 'insensitive' as const },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
}
|
|
200
|
+
: undefined;
|
|
201
|
+
|
|
202
|
+
const userIdWhere = input.userId?.trim()
|
|
203
|
+
? { userId: input.userId.trim() }
|
|
204
|
+
: undefined;
|
|
205
|
+
|
|
206
|
+
const statusWhere = input.status?.trim()
|
|
207
|
+
? { status: input.status.trim() }
|
|
208
|
+
: undefined;
|
|
209
|
+
|
|
210
|
+
const typeWhere = input.type ? { type: input.type } : undefined;
|
|
211
|
+
|
|
212
|
+
const dateWhere =
|
|
213
|
+
input.from || input.to
|
|
214
|
+
? {
|
|
215
|
+
createdAt: {
|
|
216
|
+
...(input.from ? { gte: input.from } : {}),
|
|
217
|
+
...(input.to ? { lte: input.to } : {}),
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
: undefined;
|
|
221
|
+
|
|
222
|
+
const andParts = [
|
|
223
|
+
...(searchWhere ? [searchWhere] : []),
|
|
224
|
+
...(userIdWhere ? [userIdWhere] : []),
|
|
225
|
+
...(statusWhere ? [statusWhere] : []),
|
|
226
|
+
...(typeWhere ? [typeWhere] : []),
|
|
227
|
+
...(dateWhere ? [dateWhere] : []),
|
|
228
|
+
];
|
|
229
|
+
const where = andParts.length > 0 ? { AND: andParts } : {};
|
|
230
|
+
|
|
231
|
+
const totalCount = await (ctx.db as any).invoice.count({ where });
|
|
232
|
+
const pageSize = input.pageSize;
|
|
233
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
234
|
+
const page = Math.min(Math.max(1, input.page), totalPages);
|
|
235
|
+
const skip = (page - 1) * pageSize;
|
|
236
|
+
|
|
237
|
+
const invoices = await (ctx.db as any).invoice.findMany({
|
|
238
|
+
where,
|
|
239
|
+
skip,
|
|
240
|
+
take: pageSize,
|
|
241
|
+
orderBy: { createdAt: 'desc' },
|
|
242
|
+
include: {
|
|
243
|
+
user: {
|
|
244
|
+
include: {
|
|
245
|
+
profilePicture: true,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
subscription: {
|
|
249
|
+
include: { plan: true },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const items = invoices.map((invoice: any) => ({
|
|
255
|
+
...invoice,
|
|
256
|
+
user: invoice.user
|
|
257
|
+
? {
|
|
258
|
+
...invoice.user,
|
|
259
|
+
profilePicture: invoice.user.profilePicture?.objectKey
|
|
260
|
+
? `/profile-picture/${invoice.user.profilePicture.objectKey}?t=${new Date(invoice.user.updatedAt).getTime()}`
|
|
261
|
+
: null,
|
|
262
|
+
}
|
|
263
|
+
: invoice.user,
|
|
264
|
+
}));
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
items,
|
|
268
|
+
page,
|
|
269
|
+
pageSize,
|
|
270
|
+
totalCount,
|
|
271
|
+
totalPages,
|
|
272
|
+
};
|
|
273
|
+
}),
|
|
274
|
+
|
|
275
|
+
listWorkspaces: adminProcedure
|
|
276
|
+
.input(z.object({
|
|
277
|
+
limit: z.number().min(1).max(100).default(50),
|
|
278
|
+
cursor: z.string().nullish(),
|
|
279
|
+
}))
|
|
280
|
+
.query(async ({ ctx, input }) => {
|
|
281
|
+
const workspaces = await ctx.db.workspace.findMany({
|
|
282
|
+
take: input.limit + 1,
|
|
283
|
+
cursor: input.cursor ? { id: input.cursor } : undefined,
|
|
284
|
+
orderBy: { createdAt: 'desc' },
|
|
285
|
+
include: {
|
|
286
|
+
owner: {
|
|
287
|
+
select: {
|
|
288
|
+
name: true,
|
|
289
|
+
email: true,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
let nextCursor: typeof input.cursor | undefined = undefined;
|
|
296
|
+
if (workspaces.length > input.limit) {
|
|
297
|
+
const nextItem = workspaces.pop();
|
|
298
|
+
nextCursor = nextItem!.id;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
workspaces,
|
|
303
|
+
nextCursor,
|
|
304
|
+
};
|
|
305
|
+
}),
|
|
306
|
+
|
|
307
|
+
updateUserRole: adminProcedure
|
|
308
|
+
.input(z.object({
|
|
309
|
+
userId: z.string(),
|
|
310
|
+
roleName: z.string(),
|
|
311
|
+
}))
|
|
312
|
+
.mutation(async ({ ctx, input }) => {
|
|
313
|
+
const role = await ctx.db.role.findUnique({
|
|
314
|
+
where: { name: input.roleName }
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
if (!role) {
|
|
318
|
+
throw new Error(`Role ${input.roleName} not found`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const updatedUser = await ctx.db.user.update({
|
|
322
|
+
where: { id: input.userId },
|
|
323
|
+
data: { roleId: role.id },
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
logger.info(`User ${input.userId} role updated to ${input.roleName} by admin ${ctx.userId}`, 'ADMIN');
|
|
327
|
+
return updatedUser;
|
|
328
|
+
}),
|
|
329
|
+
|
|
330
|
+
listPlans: adminProcedure
|
|
331
|
+
.query(async ({ ctx }) => {
|
|
332
|
+
return ctx.db.plan.findMany({
|
|
333
|
+
include: {
|
|
334
|
+
limit: true
|
|
335
|
+
},
|
|
336
|
+
orderBy: { price: 'asc' }
|
|
337
|
+
});
|
|
338
|
+
}),
|
|
339
|
+
|
|
340
|
+
upsertPlan: adminProcedure
|
|
341
|
+
.input(z.object({
|
|
342
|
+
id: z.string().optional(),
|
|
343
|
+
name: z.string(),
|
|
344
|
+
description: z.string().optional(),
|
|
345
|
+
type: z.string(), // subscription or credits
|
|
346
|
+
price: z.number(),
|
|
347
|
+
stripePriceId: z.string().optional(),
|
|
348
|
+
interval: z.string().nullable(),
|
|
349
|
+
active: z.boolean().default(true),
|
|
350
|
+
limits: z.object({
|
|
351
|
+
maxStorageBytes: z.number(),
|
|
352
|
+
maxWorksheets: z.number(),
|
|
353
|
+
maxFlashcards: z.number(),
|
|
354
|
+
maxPodcasts: z.number().default(0),
|
|
355
|
+
maxStudyGuides: z.number().default(0),
|
|
356
|
+
})
|
|
357
|
+
}))
|
|
358
|
+
.mutation(async ({ ctx, input }) => {
|
|
359
|
+
let { limits, id, stripePriceId, ...planData } = input;
|
|
360
|
+
|
|
361
|
+
// Automation: Create Stripe Product and Price if stripePriceId is missing (new plan)
|
|
362
|
+
if (!stripePriceId && !id) {
|
|
363
|
+
if (!stripe) {
|
|
364
|
+
throw new Error("Stripe is not configured on the server");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
// 1. Create Product
|
|
369
|
+
const product = await stripe.products.create({
|
|
370
|
+
name: planData.name,
|
|
371
|
+
description: planData.description,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// 2. Create Price
|
|
375
|
+
const price = await stripe.prices.create({
|
|
376
|
+
product: product.id,
|
|
377
|
+
unit_amount: Math.round(planData.price * 100), // convert to cents
|
|
378
|
+
currency: 'usd',
|
|
379
|
+
recurring: planData.type === 'subscription'
|
|
380
|
+
? { interval: (planData.interval as any) || 'month' }
|
|
381
|
+
: undefined,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
stripePriceId = price.id;
|
|
385
|
+
logger.info(`Automatically created Stripe Product (${product.id}) and Price (${price.id}) for plan: ${planData.name}`, 'STRIPE');
|
|
386
|
+
} catch (err: any) {
|
|
387
|
+
logger.error(`Failed to automate Stripe creation: ${err.message}`, 'STRIPE');
|
|
388
|
+
throw new Error(`Stripe error: ${err.message}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (id) {
|
|
393
|
+
// Update
|
|
394
|
+
const updatedPlan = await ctx.db.plan.update({
|
|
395
|
+
where: { id },
|
|
396
|
+
data: {
|
|
397
|
+
...planData,
|
|
398
|
+
stripePriceId,
|
|
399
|
+
limit: {
|
|
400
|
+
update: {
|
|
401
|
+
maxStorageBytes: BigInt(limits.maxStorageBytes),
|
|
402
|
+
maxWorksheets: limits.maxWorksheets,
|
|
403
|
+
maxFlashcards: limits.maxFlashcards,
|
|
404
|
+
maxPodcasts: limits.maxPodcasts,
|
|
405
|
+
maxStudyGuides: limits.maxStudyGuides,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
logger.info(`Plan ${id} updated by admin ${ctx.userId}`, 'ADMIN');
|
|
411
|
+
return updatedPlan;
|
|
412
|
+
} else {
|
|
413
|
+
// Create
|
|
414
|
+
const newPlan = await ctx.db.plan.create({
|
|
415
|
+
data: {
|
|
416
|
+
...planData,
|
|
417
|
+
stripePriceId: stripePriceId || "", // Should be set by automation above
|
|
418
|
+
limit: {
|
|
419
|
+
create: {
|
|
420
|
+
maxStorageBytes: BigInt(limits.maxStorageBytes),
|
|
421
|
+
maxWorksheets: limits.maxWorksheets,
|
|
422
|
+
maxFlashcards: limits.maxFlashcards,
|
|
423
|
+
maxPodcasts: limits.maxPodcasts,
|
|
424
|
+
maxStudyGuides: limits.maxStudyGuides,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
logger.info(`New plan ${newPlan.id} created by admin ${ctx.userId}`, 'ADMIN');
|
|
430
|
+
return newPlan;
|
|
431
|
+
}
|
|
432
|
+
}),
|
|
433
|
+
|
|
434
|
+
deletePlan: adminProcedure
|
|
435
|
+
.input(z.object({ id: z.string() }))
|
|
436
|
+
.mutation(async ({ ctx, input }) => {
|
|
437
|
+
// Check if plan has active subscriptions
|
|
438
|
+
const activeSubs = await ctx.db.subscription.count({
|
|
439
|
+
where: { planId: input.id, status: 'active' }
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
if (activeSubs > 0) {
|
|
443
|
+
// If it has active subs, we should just deactivate it instead of deleting
|
|
444
|
+
await ctx.db.plan.update({
|
|
445
|
+
where: { id: input.id },
|
|
446
|
+
data: { active: false }
|
|
447
|
+
});
|
|
448
|
+
return { success: true, message: "Plan deactivated because it has active subscribers." };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
await ctx.db.plan.delete({ where: { id: input.id } });
|
|
452
|
+
logger.info(`Plan ${input.id} deleted by admin ${ctx.userId}`, 'ADMIN');
|
|
453
|
+
return { success: true, message: "Plan deleted." };
|
|
454
|
+
}),
|
|
455
|
+
|
|
456
|
+
getUserInvoices: adminProcedure
|
|
457
|
+
.input(
|
|
458
|
+
z.object({
|
|
459
|
+
userId: z.string(),
|
|
460
|
+
/** When set, return at most this many newest invoices (e.g. 5 for user detail sheet). Omit for full history. */
|
|
461
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
462
|
+
})
|
|
463
|
+
)
|
|
464
|
+
.query(async ({ ctx, input }) => {
|
|
465
|
+
return (ctx.db as any).invoice.findMany({
|
|
466
|
+
where: { userId: input.userId },
|
|
467
|
+
orderBy: { createdAt: 'desc' },
|
|
468
|
+
take: input.limit,
|
|
469
|
+
include: {
|
|
470
|
+
subscription: {
|
|
471
|
+
include: { plan: true }
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}),
|
|
476
|
+
|
|
477
|
+
getUserDetailedInfo: adminProcedure
|
|
478
|
+
.input(z.object({ userId: z.string() }))
|
|
479
|
+
.query(async ({ ctx, input }) => {
|
|
480
|
+
const user = await ctx.db.user.findUnique({
|
|
481
|
+
where: { id: input.userId },
|
|
482
|
+
include: {
|
|
483
|
+
role: true,
|
|
484
|
+
profilePicture: true,
|
|
485
|
+
subscriptions: {
|
|
486
|
+
orderBy: { createdAt: 'desc' },
|
|
487
|
+
take: 1,
|
|
488
|
+
include: { plan: true }
|
|
489
|
+
},
|
|
490
|
+
_count: {
|
|
491
|
+
select: {
|
|
492
|
+
workspaces: true,
|
|
493
|
+
artifacts: true,
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
if (!user) throw new Error("User not found");
|
|
500
|
+
|
|
501
|
+
// Calculate total spent by this user
|
|
502
|
+
const totalSpentResult = await (ctx.db as any).invoice.aggregate({
|
|
503
|
+
where: { userId: input.userId, status: 'paid' },
|
|
504
|
+
_sum: { amountPaid: true }
|
|
505
|
+
});
|
|
506
|
+
const totalSpent = Number(totalSpentResult._sum.amountPaid || 0) / 100;
|
|
507
|
+
|
|
508
|
+
// Get purchased credits summary
|
|
509
|
+
const purchasedCredits = await ctx.db.userCredit.groupBy({
|
|
510
|
+
by: ['resourceType'],
|
|
511
|
+
where: { userId: input.userId },
|
|
512
|
+
_sum: { amount: true }
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const profilePictureUrl = (user as any).profilePicture?.objectKey
|
|
516
|
+
? `/profile-picture/${(user as any).profilePicture.objectKey}?t=${new Date(user.updatedAt).getTime()}`
|
|
517
|
+
: null;
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
...user,
|
|
521
|
+
totalSpent,
|
|
522
|
+
purchasedCredits: purchasedCredits.map(c => ({
|
|
523
|
+
type: c.resourceType,
|
|
524
|
+
amount: c._sum.amount || 0
|
|
525
|
+
})),
|
|
526
|
+
profilePicture: profilePictureUrl,
|
|
527
|
+
};
|
|
528
|
+
}),
|
|
529
|
+
|
|
530
|
+
debugInvoices: adminProcedure
|
|
531
|
+
.query(async ({ ctx }) => {
|
|
532
|
+
const count = await (ctx.db as any).invoice.count();
|
|
533
|
+
const all = await (ctx.db as any).invoice.findMany({ take: 10 });
|
|
534
|
+
return { count, all };
|
|
535
|
+
}),
|
|
536
|
+
|
|
537
|
+
listResourcePrices: adminProcedure
|
|
538
|
+
.query(async ({ ctx }) => {
|
|
539
|
+
return ctx.db.resourcePrice.findMany({
|
|
540
|
+
orderBy: { resourceType: 'asc' }
|
|
541
|
+
});
|
|
542
|
+
}),
|
|
543
|
+
|
|
544
|
+
upsertResourcePrice: adminProcedure
|
|
545
|
+
.input(z.object({
|
|
546
|
+
resourceType: z.nativeEnum(ArtifactType),
|
|
547
|
+
priceCents: z.number().min(0),
|
|
548
|
+
}))
|
|
549
|
+
.mutation(async ({ ctx, input }) => {
|
|
550
|
+
const price = await ctx.db.resourcePrice.upsert({
|
|
551
|
+
where: { resourceType: input.resourceType },
|
|
552
|
+
update: { priceCents: input.priceCents },
|
|
553
|
+
create: {
|
|
554
|
+
resourceType: input.resourceType,
|
|
555
|
+
priceCents: input.priceCents,
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
logger.info(`Resource price for ${input.resourceType} updated to ${input.priceCents} by admin ${ctx.userId}`, 'ADMIN');
|
|
559
|
+
return price;
|
|
560
|
+
}),
|
|
561
|
+
|
|
562
|
+
listRecentInvoices: adminProcedure
|
|
563
|
+
.input(z.object({ limit: z.number().default(10) }))
|
|
564
|
+
.query(async ({ ctx, input }) => {
|
|
565
|
+
const invoices = await (ctx.db as any).invoice.findMany({
|
|
566
|
+
take: input.limit,
|
|
567
|
+
orderBy: { createdAt: 'desc' },
|
|
568
|
+
include: {
|
|
569
|
+
user: {
|
|
570
|
+
include: {
|
|
571
|
+
profilePicture: true,
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return invoices.map((invoice: any) => ({
|
|
578
|
+
...invoice,
|
|
579
|
+
user: {
|
|
580
|
+
...invoice.user,
|
|
581
|
+
profilePicture: invoice.user.profilePicture?.objectKey
|
|
582
|
+
? `/profile-picture/${invoice.user.profilePicture.objectKey}?t=${new Date(invoice.user.updatedAt).getTime()}`
|
|
583
|
+
: null,
|
|
584
|
+
}
|
|
585
|
+
}));
|
|
586
|
+
}),
|
|
587
|
+
|
|
588
|
+
activityList: adminProcedure
|
|
589
|
+
.input(activityListInput)
|
|
590
|
+
.query(async ({ ctx, input }) => {
|
|
591
|
+
const where = buildActivityLogWhere({
|
|
592
|
+
from: input.from,
|
|
593
|
+
to: input.to,
|
|
594
|
+
actorUserId: input.actorUserId,
|
|
595
|
+
workspaceId: input.workspaceId,
|
|
596
|
+
category: input.category,
|
|
597
|
+
status: input.status,
|
|
598
|
+
search: input.search,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const totalCount = await ctx.db.activityLog.count({ where });
|
|
602
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / input.limit));
|
|
603
|
+
const page = Math.min(Math.max(1, input.page), totalPages);
|
|
604
|
+
const skip = (page - 1) * input.limit;
|
|
605
|
+
|
|
606
|
+
const rows = await ctx.db.activityLog.findMany({
|
|
607
|
+
where,
|
|
608
|
+
skip,
|
|
609
|
+
take: input.limit,
|
|
610
|
+
orderBy: { createdAt: 'desc' },
|
|
611
|
+
include: {
|
|
612
|
+
actor: { select: { id: true, email: true, name: true } },
|
|
613
|
+
workspace: { select: { id: true, title: true } },
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
items: rows.map((r) => ({
|
|
619
|
+
id: r.id,
|
|
620
|
+
createdAt: r.createdAt,
|
|
621
|
+
action: r.action,
|
|
622
|
+
description: getActivityHumanDescription(r.trpcPath, r.action),
|
|
623
|
+
category: r.category,
|
|
624
|
+
trpcPath: r.trpcPath,
|
|
625
|
+
status: r.status,
|
|
626
|
+
durationMs: r.durationMs,
|
|
627
|
+
errorCode: r.errorCode,
|
|
628
|
+
ipAddress: r.ipAddress,
|
|
629
|
+
actor: r.actor,
|
|
630
|
+
workspace: r.workspace,
|
|
631
|
+
metadata: r.metadata,
|
|
632
|
+
})),
|
|
633
|
+
page,
|
|
634
|
+
pageSize: input.limit,
|
|
635
|
+
totalCount,
|
|
636
|
+
totalPages,
|
|
637
|
+
};
|
|
638
|
+
}),
|
|
639
|
+
|
|
640
|
+
activityExportCsv: adminProcedure
|
|
641
|
+
.input(
|
|
642
|
+
activityLogFiltersInput.extend({
|
|
643
|
+
maxRows: z.number().min(1).max(5000).default(2000),
|
|
644
|
+
})
|
|
645
|
+
)
|
|
646
|
+
.query(async ({ ctx, input }) => {
|
|
647
|
+
const where = buildActivityLogWhere({
|
|
648
|
+
from: input.from,
|
|
649
|
+
to: input.to,
|
|
650
|
+
actorUserId: input.actorUserId,
|
|
651
|
+
workspaceId: input.workspaceId,
|
|
652
|
+
category: input.category,
|
|
653
|
+
status: input.status,
|
|
654
|
+
search: input.search,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const rows = await ctx.db.activityLog.findMany({
|
|
658
|
+
where,
|
|
659
|
+
orderBy: { createdAt: 'desc' },
|
|
660
|
+
take: input.maxRows,
|
|
661
|
+
include: {
|
|
662
|
+
actor: { select: { email: true, name: true } },
|
|
663
|
+
workspace: { select: { title: true } },
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
const header = [
|
|
668
|
+
'createdAt',
|
|
669
|
+
'actorEmail',
|
|
670
|
+
'actorName',
|
|
671
|
+
'description',
|
|
672
|
+
'trpcPath',
|
|
673
|
+
'category',
|
|
674
|
+
'status',
|
|
675
|
+
'durationMs',
|
|
676
|
+
'workspaceTitle',
|
|
677
|
+
'errorCode',
|
|
678
|
+
].join(',');
|
|
679
|
+
|
|
680
|
+
const lines = rows.map((r) =>
|
|
681
|
+
[
|
|
682
|
+
csvEscapeCell(r.createdAt.toISOString()),
|
|
683
|
+
csvEscapeCell(r.actor?.email),
|
|
684
|
+
csvEscapeCell(r.actor?.name),
|
|
685
|
+
csvEscapeCell(getActivityHumanDescription(r.trpcPath, r.action)),
|
|
686
|
+
csvEscapeCell(r.trpcPath),
|
|
687
|
+
csvEscapeCell(r.category),
|
|
688
|
+
csvEscapeCell(r.status),
|
|
689
|
+
csvEscapeCell(r.durationMs),
|
|
690
|
+
csvEscapeCell(r.workspace?.title),
|
|
691
|
+
csvEscapeCell(r.errorCode),
|
|
692
|
+
].join(',')
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
csv: [header, ...lines].join('\n'),
|
|
697
|
+
count: rows.length,
|
|
698
|
+
};
|
|
699
|
+
}),
|
|
700
|
+
|
|
701
|
+
/** Deletes rows older than ACTIVITY_LOG_RETENTION_DAYS (default 365). Run manually or via cron. */
|
|
702
|
+
activityPurgeRetention: adminProcedure.mutation(async ({ ctx }) => {
|
|
703
|
+
const days = getActivityRetentionDays();
|
|
704
|
+
const cutoff = new Date();
|
|
705
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
706
|
+
const { deleted } = await deleteActivityLogsOlderThan(ctx.db, cutoff);
|
|
707
|
+
logger.info(`ActivityLog retention purge: deleted ${deleted} rows older than ${days} days (cutoff ${cutoff.toISOString()})`, 'ADMIN');
|
|
708
|
+
return { deleted, retentionDays: days, cutoff };
|
|
709
|
+
}),
|
|
710
|
+
});
|