Skip to content

Commit b6ddee1

Browse files
committed
add database transactions
1 parent 769e872 commit b6ddee1

File tree

1 file changed

+100
-53
lines changed

1 file changed

+100
-53
lines changed

packages/rpc/src/routers/websites.ts

Lines changed: 100 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,27 @@ import {
1818
trackWebsiteUsage,
1919
} from '../utils/billing';
2020

21+
// Cache configuration
2122
const websiteCache = createDrizzleCache({ redis, namespace: 'websites' });
22-
const CACHE_DURATION = 60;
23-
const TREND_THRESHOLD = 5;
24-
25-
const buildFullDomain = (domain: string, subdomain?: string) =>
26-
subdomain ? `${subdomain}.${domain}` : domain;
23+
const CACHE_DURATION = 60; // seconds
24+
const TREND_THRESHOLD = 5; // percentage
2725

26+
// Types
2827
interface ChartDataPoint {
2928
websiteId: string;
3029
date: string;
3130
value: number;
3231
}
3332

33+
// Helper functions
34+
const buildFullDomain = (domain: string, subdomain?: string) =>
35+
subdomain ? `${subdomain}.${domain}` : domain;
36+
37+
const buildWebsiteFilter = (userId: string, organizationId?: string) =>
38+
organizationId
39+
? eq(websites.organizationId, organizationId)
40+
: and(eq(websites.userId, userId), isNull(websites.organizationId));
41+
3442
const calculateAverage = (values: { value: number }[]) =>
3543
values.length > 0
3644
? values.reduce((sum, item) => sum + item.value, 0) / values.length
@@ -140,11 +148,7 @@ const fetchChartData = async (
140148
return processedData;
141149
};
142150

143-
const buildWebsiteFilter = (userId: string, organizationId?: string) =>
144-
organizationId
145-
? eq(websites.organizationId, organizationId)
146-
: and(eq(websites.userId, userId), isNull(websites.organizationId));
147-
151+
// Router definition
148152
export const websitesRouter = createTRPCRouter({
149153
list: protectedProcedure
150154
.input(z.object({ organizationId: z.string().optional() }).default({}))
@@ -213,6 +217,7 @@ export const websitesRouter = createTRPCRouter({
213217
create: protectedProcedure
214218
.input(createWebsiteSchema)
215219
.mutation(async ({ ctx, input }) => {
220+
// Validate organization permissions upfront
216221
if (input.organizationId) {
217222
const { success } = await websitesApi.hasPermission({
218223
headers: ctx.headers,
@@ -230,6 +235,8 @@ export const websitesRouter = createTRPCRouter({
230235
ctx.user.id,
231236
input.organizationId
232237
);
238+
239+
// Check billing limits before starting transaction
233240
const creationLimitCheck =
234241
await checkAndTrackWebsiteCreation(billingCustomerId);
235242
if (!creationLimitCheck.allowed) {
@@ -245,32 +252,40 @@ export const websitesRouter = createTRPCRouter({
245252
buildWebsiteFilter(ctx.user.id, input.organizationId)
246253
);
247254

248-
const duplicateWebsite = await ctx.db.query.websites.findFirst({
249-
where: websiteFilter,
250-
});
251-
252-
if (duplicateWebsite) {
253-
const scopeDescription = input.organizationId
254-
? 'in this organization'
255-
: 'for your account';
256-
throw new TRPCError({
257-
code: 'CONFLICT',
258-
message: `A website with the domain "${domainToCreate}" already exists ${scopeDescription}.`,
255+
// Execute database operations in transaction
256+
const createdWebsite = await ctx.db.transaction(async (tx) => {
257+
// Check for duplicate websites within transaction
258+
const duplicateWebsite = await tx.query.websites.findFirst({
259+
where: websiteFilter,
259260
});
260-
}
261261

262-
const [createdWebsite] = await ctx.db
263-
.insert(websites)
264-
.values({
265-
id: nanoid(),
266-
name: input.name,
267-
domain: domainToCreate,
268-
userId: ctx.user.id,
269-
organizationId: input.organizationId,
270-
status: 'ACTIVE',
271-
})
272-
.returning();
262+
if (duplicateWebsite) {
263+
const scopeDescription = input.organizationId
264+
? 'in this organization'
265+
: 'for your account';
266+
throw new TRPCError({
267+
code: 'CONFLICT',
268+
message: `A website with the domain "${domainToCreate}" already exists ${scopeDescription}.`,
269+
});
270+
}
273271

272+
// Create website
273+
const [website] = await tx
274+
.insert(websites)
275+
.values({
276+
id: nanoid(),
277+
name: input.name,
278+
domain: domainToCreate,
279+
userId: ctx.user.id,
280+
organizationId: input.organizationId,
281+
status: 'ACTIVE',
282+
})
283+
.returning();
284+
285+
return website;
286+
});
287+
288+
// Log success after transaction completes
274289
logger.success(
275290
'Website Created',
276291
`New website "${createdWebsite.name}" was created with domain "${createdWebsite.domain}"`,
@@ -282,6 +297,7 @@ export const websitesRouter = createTRPCRouter({
282297
}
283298
);
284299

300+
// Invalidate cache after successful creation
285301
await websiteCache.invalidateByTables(['websites']);
286302

287303
return createdWebsite;
@@ -290,18 +306,32 @@ export const websitesRouter = createTRPCRouter({
290306
update: protectedProcedure
291307
.input(updateWebsiteSchema)
292308
.mutation(async ({ ctx, input }) => {
309+
// Authorize access before transaction
293310
const websiteToUpdate = await authorizeWebsiteAccess(
294311
ctx,
295312
input.id,
296313
'update'
297314
);
298315

299-
const [updatedWebsite] = await ctx.db
300-
.update(websites)
301-
.set({ name: input.name })
302-
.where(eq(websites.id, input.id))
303-
.returning();
316+
// Execute update in transaction
317+
const updatedWebsite = await ctx.db.transaction(async (tx) => {
318+
const [website] = await tx
319+
.update(websites)
320+
.set({ name: input.name })
321+
.where(eq(websites.id, input.id))
322+
.returning();
323+
324+
if (!website) {
325+
throw new TRPCError({
326+
code: 'NOT_FOUND',
327+
message: 'Website not found',
328+
});
329+
}
304330

331+
return website;
332+
});
333+
334+
// Log success after transaction
305335
logger.info(
306336
'Website Updated',
307337
`Website "${websiteToUpdate.name}" was renamed to "${updatedWebsite.name}"`,
@@ -313,6 +343,7 @@ export const websitesRouter = createTRPCRouter({
313343
}
314344
);
315345

346+
// Invalidate cache after successful update
316347
await Promise.all([
317348
websiteCache.invalidateByTables(['websites']),
318349
websiteCache.invalidateByKey(`getById:${input.id}`),
@@ -324,6 +355,7 @@ export const websitesRouter = createTRPCRouter({
324355
delete: protectedProcedure
325356
.input(z.object({ id: z.string() }))
326357
.mutation(async ({ ctx, input }) => {
358+
// Authorize access and get billing info before transaction
327359
const websiteToDelete = await authorizeWebsiteAccess(
328360
ctx,
329361
input.id,
@@ -334,11 +366,16 @@ export const websitesRouter = createTRPCRouter({
334366
websiteToDelete.organizationId
335367
);
336368

337-
await Promise.all([
338-
ctx.db.delete(websites).where(eq(websites.id, input.id)),
339-
trackWebsiteUsage(billingCustomerId, -1),
340-
]);
369+
// Execute deletion and billing update in transaction
370+
await ctx.db.transaction(async (tx) => {
371+
// Delete website
372+
await tx.delete(websites).where(eq(websites.id, input.id));
373+
374+
// Track billing usage (decrement)
375+
await trackWebsiteUsage(billingCustomerId, -1);
376+
});
341377

378+
// Log after successful deletion
342379
logger.warning(
343380
'Website Deleted',
344381
`Website "${websiteToDelete.name}" with domain "${websiteToDelete.domain}" was deleted`,
@@ -350,6 +387,7 @@ export const websitesRouter = createTRPCRouter({
350387
}
351388
);
352389

390+
// Invalidate cache after successful deletion
353391
await Promise.all([
354392
websiteCache.invalidateByTables(['websites']),
355393
websiteCache.invalidateByKey(`getById:${input.id}`),
@@ -361,8 +399,10 @@ export const websitesRouter = createTRPCRouter({
361399
transfer: protectedProcedure
362400
.input(transferWebsiteSchema)
363401
.mutation(async ({ ctx, input }) => {
402+
// Authorize access before transaction
364403
await authorizeWebsiteAccess(ctx, input.websiteId, 'update');
365404

405+
// Validate organization permissions upfront
366406
if (input.organizationId) {
367407
const { success } = await websitesApi.hasPermission({
368408
headers: ctx.headers,
@@ -376,19 +416,25 @@ export const websitesRouter = createTRPCRouter({
376416
}
377417
}
378418

379-
const [transferredWebsite] = await ctx.db
380-
.update(websites)
381-
.set({ organizationId: input.organizationId ?? null })
382-
.where(eq(websites.id, input.websiteId))
383-
.returning();
419+
// Execute transfer in transaction
420+
const transferredWebsite = await ctx.db.transaction(async (tx) => {
421+
const [website] = await tx
422+
.update(websites)
423+
.set({ organizationId: input.organizationId ?? null })
424+
.where(eq(websites.id, input.websiteId))
425+
.returning();
384426

385-
if (!transferredWebsite) {
386-
throw new TRPCError({
387-
code: 'NOT_FOUND',
388-
message: 'Website not found',
389-
});
390-
}
427+
if (!website) {
428+
throw new TRPCError({
429+
code: 'NOT_FOUND',
430+
message: 'Website not found',
431+
});
432+
}
433+
434+
return website;
435+
});
391436

437+
// Log success after transaction
392438
logger.success(
393439
'Website Transferred',
394440
`Website "${transferredWebsite.name}" was transferred to organization "${input.organizationId}"`,
@@ -399,6 +445,7 @@ export const websitesRouter = createTRPCRouter({
399445
}
400446
);
401447

448+
// Invalidate cache after successful transfer
402449
await Promise.all([
403450
websiteCache.invalidateByTables(['websites']),
404451
websiteCache.invalidateByKey(`getById:${input.websiteId}`),

0 commit comments

Comments
 (0)