Skip to content

Commit 8703e5b

Browse files
authored
Merge pull request #327 from apsinghdev/feat/pro-sessions
[feat] add pro session
2 parents 0a9536c + eb8c791 commit 8703e5b

13 files changed

Lines changed: 667 additions & 36 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,7 @@ yarn-error.log*
3636
# Misc
3737
.DS_Store
3838
*.pem
39+
40+
# specific file
41+
seed-sessions.js
42+
howtos.md
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
-- CreateTable
2+
CREATE TABLE "WeeklySession" (
3+
"id" TEXT NOT NULL,
4+
"title" TEXT NOT NULL,
5+
"description" TEXT,
6+
"youtubeUrl" TEXT NOT NULL,
7+
"sessionDate" TIMESTAMP(3) NOT NULL,
8+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
"updatedAt" TIMESTAMP(3) NOT NULL,
10+
11+
CONSTRAINT "WeeklySession_pkey" PRIMARY KEY ("id")
12+
);
13+
14+
-- CreateTable
15+
CREATE TABLE "SessionTopic" (
16+
"id" TEXT NOT NULL,
17+
"sessionId" TEXT NOT NULL,
18+
"timestamp" TEXT NOT NULL,
19+
"topic" TEXT NOT NULL,
20+
"order" INTEGER NOT NULL,
21+
22+
CONSTRAINT "SessionTopic_pkey" PRIMARY KEY ("id")
23+
);
24+
25+
-- CreateIndex
26+
CREATE INDEX "WeeklySession_sessionDate_idx" ON "WeeklySession"("sessionDate");
27+
28+
-- CreateIndex
29+
CREATE INDEX "WeeklySession_createdAt_idx" ON "WeeklySession"("createdAt");
30+
31+
-- CreateIndex
32+
CREATE INDEX "SessionTopic_sessionId_order_idx" ON "SessionTopic"("sessionId", "order");
33+
34+
-- AddForeignKey
35+
ALTER TABLE "SessionTopic" ADD CONSTRAINT "SessionTopic_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklySession"("id") ON DELETE CASCADE ON UPDATE CASCADE;

apps/api/prisma/schema.prisma

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,28 @@ model Plan {
115115
updatedAt DateTime @updatedAt
116116
subscriptions Subscription[]
117117
}
118+
119+
model WeeklySession {
120+
id String @id @default(cuid())
121+
title String
122+
description String?
123+
youtubeUrl String
124+
sessionDate DateTime
125+
createdAt DateTime @default(now())
126+
updatedAt DateTime @updatedAt
127+
topics SessionTopic[]
128+
129+
@@index([sessionDate])
130+
@@index([createdAt])
131+
}
132+
133+
model SessionTopic {
134+
id String @id @default(cuid())
135+
sessionId String
136+
timestamp String
137+
topic String
138+
order Int
139+
session WeeklySession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
140+
141+
@@index([sessionId, order])
142+
}

apps/api/src/routers/_app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { userRouter } from "./user.js";
44
import { projectRouter } from "./projects.js";
55
import { authRouter } from "./auth.js";
66
import { paymentRouter } from "./payment.js";
7+
import { sessionsRouter } from "./sessions.js";
78
import { testimonialRouter } from "./testimonial.js";
89
import { z } from "zod";
910

@@ -22,6 +23,7 @@ export const appRouter = router({
2223
project: projectRouter,
2324
auth: authRouter,
2425
payment: paymentRouter,
26+
sessions: sessionsRouter,
2527
testimonial: testimonialRouter,
2628
});
2729

apps/api/src/routers/sessions.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { TRPCError } from "@trpc/server";
2+
import { router, protectedProcedure, type ProtectedContext } from "../trpc.js";
3+
import {
4+
sessionService,
5+
AuthorizationError,
6+
} from "../services/session.service.js";
7+
8+
export const sessionsRouter = router({
9+
getAll: protectedProcedure.query(async ({ ctx }) => {
10+
const userId = (ctx as ProtectedContext).user.id;
11+
12+
try {
13+
return await sessionService.getSessions(ctx.db.prisma, userId);
14+
} catch (error) {
15+
if (error instanceof AuthorizationError) {
16+
throw new TRPCError({
17+
code: "FORBIDDEN",
18+
message: error.message,
19+
});
20+
}
21+
throw error;
22+
}
23+
}),
24+
});
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type { Prisma, PrismaClient } from "@prisma/client";
2+
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
3+
import type { ExtendedPrismaClient } from "../prisma.js";
4+
import { SUBSCRIPTION_STATUS } from "../constants/subscription.js";
5+
6+
export type SessionWithTopics = Prisma.WeeklySessionGetPayload<{
7+
select: {
8+
id: true;
9+
title: true;
10+
description: true;
11+
youtubeUrl: true;
12+
sessionDate: true;
13+
topics: {
14+
select: {
15+
id: true;
16+
timestamp: true;
17+
topic: true;
18+
order: true;
19+
};
20+
};
21+
};
22+
}>;
23+
24+
export class AuthorizationError extends Error {
25+
constructor(message: string) {
26+
super(message);
27+
this.name = "AuthorizationError";
28+
}
29+
}
30+
31+
/**
32+
* retry helper with exponential backoff for transient db failures
33+
*/
34+
async function withRetry<T>(
35+
operation: () => Promise<T>,
36+
options: {
37+
maxRetries?: number;
38+
baseDelay?: number;
39+
multiplier?: number;
40+
operationName?: string;
41+
} = {}
42+
): Promise<T> {
43+
const {
44+
maxRetries = 3,
45+
baseDelay = 100,
46+
multiplier = 2,
47+
operationName = "database operation",
48+
} = options;
49+
50+
let lastError: Error | undefined;
51+
52+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
53+
try {
54+
return await operation();
55+
} catch (error) {
56+
lastError = error as Error;
57+
const isTransient =
58+
error instanceof PrismaClientKnownRequestError &&
59+
(error.code === "P1001" || // connection error
60+
error.code === "P1002" || // connection timeout
61+
error.code === "P1008" || // operations timed out
62+
error.code === "P1017"); // server closed connection
63+
64+
const isNetworkError =
65+
error instanceof Error &&
66+
(error.message.includes("ECONNREFUSED") ||
67+
error.message.includes("ETIMEDOUT") ||
68+
error.message.includes("ENOTFOUND"));
69+
70+
if (!isTransient && !isNetworkError) {
71+
throw error;
72+
}
73+
74+
if (attempt < maxRetries) {
75+
const delay = baseDelay * Math.pow(multiplier, attempt);
76+
console.warn(
77+
`[${new Date().toISOString()}] ${operationName} failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms...`,
78+
error
79+
);
80+
await new Promise((resolve) => setTimeout(resolve, delay));
81+
}
82+
}
83+
}
84+
85+
throw lastError;
86+
}
87+
88+
export const sessionService = {
89+
/**
90+
* Get all sessions for authenticated paid users
91+
* Sessions are ordered by sessionDate descending (newest first)
92+
*/
93+
async getSessions(
94+
prisma: ExtendedPrismaClient | PrismaClient,
95+
userId: string
96+
): Promise<SessionWithTopics[]> {
97+
const subscription = await withRetry(
98+
() =>
99+
prisma.subscription.findFirst({
100+
where: {
101+
userId,
102+
status: SUBSCRIPTION_STATUS.ACTIVE,
103+
endDate: {
104+
gte: new Date(),
105+
},
106+
},
107+
}),
108+
{ operationName: "subscription check" }
109+
);
110+
111+
if (!subscription) {
112+
throw new AuthorizationError(
113+
"Active subscription required to access sessions"
114+
);
115+
}
116+
117+
try {
118+
const sessions = await withRetry(
119+
() =>
120+
prisma.weeklySession.findMany({
121+
select: {
122+
id: true,
123+
title: true,
124+
description: true,
125+
youtubeUrl: true,
126+
sessionDate: true,
127+
topics: {
128+
select: {
129+
id: true,
130+
timestamp: true,
131+
topic: true,
132+
order: true,
133+
},
134+
orderBy: {
135+
order: "asc",
136+
},
137+
},
138+
},
139+
orderBy: {
140+
sessionDate: "desc",
141+
},
142+
}),
143+
{ operationName: "session fetch" }
144+
);
145+
146+
return sessions;
147+
} catch (error) {
148+
const timestamp = new Date().toISOString();
149+
const functionName = "getSessions";
150+
151+
console.error(
152+
`[${timestamp}] Error in sessionService.${functionName} - userId: ${userId}`,
153+
error
154+
);
155+
156+
throw error;
157+
}
158+
},
159+
};

apps/api/src/trpc.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { initTRPC, TRPCError } from "@trpc/server";
1+
import { initTRPC, TRPCError, type AnyProcedure } from "@trpc/server";
22
import superjson from "superjson";
33
import type { Context } from "./context.js";
44
import { verifyToken } from "./utils/auth.js";
5+
import type { User } from "@prisma/client";
56

67
const t = initTRPC.context<Context>().create({
78
transformer: superjson,
@@ -35,6 +36,10 @@ const isAuthed = t.middleware(async ({ ctx, next }) => {
3536
}
3637
});
3738

39+
export type ProtectedContext = Context & { user: User };
40+
3841
export const router = t.router;
3942
export const publicProcedure = t.procedure;
40-
export const protectedProcedure:any = t.procedure.use(isAuthed);
43+
export const protectedProcedure: typeof t.procedure = t.procedure.use(
44+
isAuthed
45+
) as any;

apps/web/src/app/(main)/dashboard/pro/dashboard/page.tsx

Lines changed: 21 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { useSubscription } from "@/hooks/useSubscription";
44
import { useRouter } from "next/navigation";
55
import { useEffect, useState } from "react";
66
import { useSession } from "next-auth/react";
7-
import { trpc } from "@/lib/trpc";
7+
import Link from "next/link";
8+
import { Play } from "lucide-react";
89

910
export default function ProDashboardPage() {
1011
const { isPaidUser, isLoading } = useSubscription();
@@ -13,19 +14,6 @@ export default function ProDashboardPage() {
1314
const [error, setError] = useState<string | null>(null);
1415
const [isJoining, setIsJoining] = useState(false);
1516

16-
// Check if user has already submitted a testimonial
17-
const { data: testimonialData } = (
18-
trpc as any
19-
).testimonial.getMyTestimonial.useQuery(undefined, {
20-
enabled: !!isPaidUser,
21-
retry: false,
22-
refetchOnWindowFocus: true,
23-
refetchOnMount: true,
24-
staleTime: 0, // Always fetch fresh data
25-
});
26-
27-
const hasSubmittedTestimonial = !!testimonialData?.testimonial;
28-
2917
useEffect(() => {
3018
if (!isLoading && !isPaidUser) {
3119
router.push("/pricing");
@@ -94,7 +82,7 @@ export default function ProDashboardPage() {
9482
return;
9583
}
9684

97-
window.open(slackInviteUrl, "_blank", "noopener,noreferrer");
85+
window.location.href = slackInviteUrl;
9886
} catch (err) {
9987
console.error("Failed to join community:", err);
10088
setError("Failed to connect to server");
@@ -123,27 +111,26 @@ export default function ProDashboardPage() {
123111
soon you&apos;ll see all the pro perks here. thanks for investin!
124112
</h1>
125113
{isPaidUser && (
126-
<div className="mt-6">
127-
<div className="flex flex-wrap gap-4 justify-center">
128-
<button
129-
onClick={handleJoinSlack}
130-
disabled={isJoining}
131-
className="px-4 py-2 bg-brand-purple hover:bg-brand-purple-light text-text-primary font-medium rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
132-
>
133-
{isJoining ? "Joining..." : "Join Slack"}
134-
</button>
135-
{!hasSubmittedTestimonial && (
136-
<button
137-
onClick={() => router.push("/testimonials/submit")}
138-
className="px-4 py-2 bg-brand-purple hover:bg-brand-purple-light text-text-primary font-medium rounded-lg transition-colors duration-200 text-sm"
139-
>
140-
Submit Testimonial
141-
</button>
142-
)}
143-
</div>
144-
{error && <p className="text-error-text text-sm mt-2">{error}</p>}
114+
<div className="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3">
115+
<Link
116+
href="/dashboard/pro/sessions"
117+
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-purple hover:bg-brand-purple-light text-text-primary font-medium rounded-lg transition-colors duration-200 text-sm"
118+
>
119+
<Play className="w-4 h-4" />
120+
Pro Sessions
121+
</Link>
122+
<button
123+
onClick={handleJoinSlack}
124+
disabled={isJoining}
125+
className="px-4 py-2 bg-dash-surface border border-dash-border hover:border-brand-purple/50 text-text-primary font-medium rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
126+
>
127+
{isJoining ? "Joining..." : "Join Slack"}
128+
</button>
145129
</div>
146130
)}
131+
{error && (
132+
<p className="text-error-text text-sm mt-2 text-center">{error}</p>
133+
)}
147134
</div>
148135
</div>
149136
);

0 commit comments

Comments
 (0)