87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = "TableCell"
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = "TableCaption"
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
121 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | })
20 | Textarea.displayName = "Textarea"
21 |
22 | export { Textarea }
23 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ToastPrimitives from "@radix-ui/react-toast"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const ToastProvider = ToastPrimitives.Provider
11 |
12 | const ToastViewport = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | ))
25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
26 |
27 | const toastVariants = cva(
28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
29 | {
30 | variants: {
31 | variant: {
32 | default: "border bg-background text-foreground",
33 | destructive:
34 | "destructive group border-destructive bg-destructive text-destructive-foreground",
35 | },
36 | },
37 | defaultVariants: {
38 | variant: "default",
39 | },
40 | }
41 | )
42 |
43 | const Toast = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef &
46 | VariantProps
47 | >(({ className, variant, ...props }, ref) => {
48 | return (
49 |
54 | )
55 | })
56 | Toast.displayName = ToastPrimitives.Root.displayName
57 |
58 | const ToastAction = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
70 | ))
71 | ToastAction.displayName = ToastPrimitives.Action.displayName
72 |
73 | const ToastClose = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
86 |
87 |
88 | ))
89 | ToastClose.displayName = ToastPrimitives.Close.displayName
90 |
91 | const ToastTitle = React.forwardRef<
92 | React.ElementRef,
93 | React.ComponentPropsWithoutRef
94 | >(({ className, ...props }, ref) => (
95 |
100 | ))
101 | ToastTitle.displayName = ToastPrimitives.Title.displayName
102 |
103 | const ToastDescription = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | ToastDescription.displayName = ToastPrimitives.Description.displayName
114 |
115 | type ToastProps = React.ComponentPropsWithoutRef
116 |
117 | type ToastActionElement = React.ReactElement
118 |
119 | export {
120 | type ToastProps,
121 | type ToastActionElement,
122 | ToastProvider,
123 | ToastViewport,
124 | Toast,
125 | ToastTitle,
126 | ToastDescription,
127 | ToastClose,
128 | ToastAction,
129 | }
130 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useToast } from "@/hooks/use-toast"
4 | import {
5 | Toast,
6 | ToastClose,
7 | ToastDescription,
8 | ToastProvider,
9 | ToastTitle,
10 | ToastViewport,
11 | } from "@/components/ui/toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/data/env/client.ts:
--------------------------------------------------------------------------------
1 | import { createEnv } from "@t3-oss/env-nextjs"
2 | import { z } from "zod"
3 |
4 | export const env = createEnv({
5 | client: {
6 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
7 | NEXT_PUBLIC_CLERK_SIGN_IN_URL: z.string().min(1),
8 | NEXT_PUBLIC_CLERK_SIGN_UP_URL: z.string().min(1),
9 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
10 | NEXT_PUBLIC_SERVER_URL: z.string().min(1),
11 | },
12 | experimental__runtimeEnv: {
13 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
14 | process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
15 | NEXT_PUBLIC_CLERK_SIGN_IN_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL,
16 | NEXT_PUBLIC_CLERK_SIGN_UP_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL,
17 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
18 | process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
19 | NEXT_PUBLIC_SERVER_URL: process.env.NEXT_PUBLIC_SERVER_URL,
20 | },
21 | })
22 |
--------------------------------------------------------------------------------
/src/data/env/server.ts:
--------------------------------------------------------------------------------
1 | import { createEnv } from "@t3-oss/env-nextjs"
2 | import { z } from "zod"
3 |
4 | export const env = createEnv({
5 | server: {
6 | DB_PASSWORD: z.string().min(1),
7 | DB_USER: z.string().min(1),
8 | DB_NAME: z.string().min(1),
9 | DB_HOST: z.string().min(1),
10 | CLERK_SECRET_KEY: z.string().min(1),
11 | CLERK_WEBHOOK_SECRET: z.string().min(1),
12 | ARCJET_KEY: z.string().min(1),
13 | TEST_IP_ADDRESS: z.string().min(1).optional(),
14 | STRIPE_PPP_50_COUPON_ID: z.string().min(1),
15 | STRIPE_PPP_40_COUPON_ID: z.string().min(1),
16 | STRIPE_PPP_30_COUPON_ID: z.string().min(1),
17 | STRIPE_PPP_20_COUPON_ID: z.string().min(1),
18 | STRIPE_SECRET_KEY: z.string().min(1),
19 | STRIPE_WEBHOOK_SECRET: z.string().min(1),
20 | },
21 | experimental__runtimeEnv: process.env,
22 | })
23 |
--------------------------------------------------------------------------------
/src/data/pppCoupons.ts:
--------------------------------------------------------------------------------
1 | import { env } from "./env/server"
2 |
3 | export const pppCoupons = [
4 | {
5 | stripeCouponId: env.STRIPE_PPP_50_COUPON_ID,
6 | discountPercentage: 0.5,
7 | countryCodes: [
8 | "AF",
9 | "EG",
10 | "IR",
11 | "KG",
12 | "LK",
13 | "BT",
14 | "LA",
15 | "LB",
16 | "LY",
17 | "MM",
18 | "PK",
19 | "SL",
20 | "TJ",
21 | "NP",
22 | "UZ",
23 | "SD",
24 | "IN",
25 | "MG",
26 | "TR",
27 | "AL",
28 | "BA",
29 | "CM",
30 | "BD",
31 | "BF",
32 | "BJ",
33 | "JO",
34 | "BI",
35 | "CO",
36 | "CI",
37 | "FJ",
38 | "ET",
39 | "GE",
40 | "KM",
41 | "LS",
42 | "KH",
43 | "AM",
44 | "BO",
45 | "BY",
46 | "DZ",
47 | "ER",
48 | "GH",
49 | "GM",
50 | "GW",
51 | "ID",
52 | "KE",
53 | "KZ",
54 | "MD",
55 | "MK",
56 | "ML",
57 | "MW",
58 | "MY",
59 | "MZ",
60 | "NG",
61 | "NI",
62 | "PH",
63 | "PY",
64 | "RW",
65 | "TH",
66 | "TZ",
67 | "UA",
68 | "UG",
69 | "VN",
70 | "MN",
71 | "MR",
72 | "MU",
73 | "SO",
74 | "TN",
75 | "ZM",
76 | "ME",
77 | "RO",
78 | "RS",
79 | "SN",
80 | "MA",
81 | "NE",
82 | "SR",
83 | "SZ",
84 | "TG",
85 | "EC",
86 | "BG",
87 | "HR",
88 | "BW",
89 | "AO",
90 | "AZ",
91 | "CF",
92 | "CV",
93 | "GY",
94 | "HU",
95 | "GQ",
96 | "HN",
97 | "BH",
98 | "CD",
99 | "DO",
100 | "GN",
101 | "LR",
102 | "PA",
103 | "NA",
104 | "PE",
105 | "PL",
106 | "SC",
107 | "SV",
108 | "TW",
109 | "MV",
110 | "TD",
111 | "YE",
112 | "ZA",
113 | "RU",
114 | ],
115 | },
116 | {
117 | stripeCouponId: env.STRIPE_PPP_40_COUPON_ID,
118 | discountPercentage: 0.4,
119 | countryCodes: [
120 | "GR",
121 | "KN",
122 | "AR",
123 | "BR",
124 | "CN",
125 | "DJ",
126 | "IQ",
127 | "JM",
128 | "GT",
129 | "LT",
130 | "CL",
131 | "CR",
132 | "CZ",
133 | "GA",
134 | "GD",
135 | "HT",
136 | "LV",
137 | "ST",
138 | "VC",
139 | "PT",
140 | "MX",
141 | "SA",
142 | "SI",
143 | "SK",
144 | "TM",
145 | "BN",
146 | "MO",
147 | "TL",
148 | ],
149 | },
150 | {
151 | stripeCouponId: env.STRIPE_PPP_30_COUPON_ID,
152 | discountPercentage: 0.3,
153 | countryCodes: [
154 | "AE",
155 | "ES",
156 | "AW",
157 | "CY",
158 | "EE",
159 | "IT",
160 | "KR",
161 | "BZ",
162 | "CG",
163 | "MT",
164 | "SG",
165 | "DM",
166 | "TO",
167 | "VE",
168 | "WS",
169 | "OM",
170 | "ZW",
171 | ],
172 | },
173 | {
174 | stripeCouponId: env.STRIPE_PPP_20_COUPON_ID,
175 | discountPercentage: 0.2,
176 | countryCodes: [
177 | "AT",
178 | "JP",
179 | "BE",
180 | "BS",
181 | "DE",
182 | "FR",
183 | "KI",
184 | "KW",
185 | "HK",
186 | "LC",
187 | "AG",
188 | "QA",
189 | "PG",
190 | "TT",
191 | "UY",
192 | ],
193 | },
194 | ]
195 |
--------------------------------------------------------------------------------
/src/data/typeOverrides/clerk.d.ts:
--------------------------------------------------------------------------------
1 | import { UserRole } from "@/drizzle/schema"
2 |
3 | export {}
4 |
5 | declare global {
6 | interface CustomJwtSessionClaims {
7 | dbId?: string
8 | role?: UserRole
9 | }
10 |
11 | interface UserPublicMetadata {
12 | dbId?: string
13 | role?: UserRole
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/drizzle/db.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/data/env/server"
2 | import { drizzle } from "drizzle-orm/node-postgres"
3 | import * as schema from "./schema"
4 |
5 | export const db = drizzle({
6 | schema,
7 | connection: {
8 | password: env.DB_PASSWORD,
9 | user: env.DB_USER,
10 | database: env.DB_NAME,
11 | host: env.DB_HOST,
12 | },
13 | })
14 |
--------------------------------------------------------------------------------
/src/drizzle/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1736952636321,
9 | "tag": "0000_orange_wind_dancer",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/src/drizzle/schema.ts:
--------------------------------------------------------------------------------
1 | export * from "./schema/course"
2 | export * from "./schema/courseProduct"
3 | export * from "./schema/courseSection"
4 | export * from "./schema/lesson"
5 | export * from "./schema/product"
6 | export * from "./schema/purchase"
7 | export * from "./schema/user"
8 | export * from "./schema/userCourseAccess"
9 | export * from "./schema/userLessonComplete"
10 |
--------------------------------------------------------------------------------
/src/drizzle/schema/course.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm"
2 | import { pgTable, text } from "drizzle-orm/pg-core"
3 | import { createdAt, id, updatedAt } from "../schemaHelpers"
4 | import { CourseProductTable } from "./courseProduct"
5 | import { UserCourseAccessTable } from "./userCourseAccess"
6 | import { CourseSectionTable } from "./courseSection"
7 |
8 | export const CourseTable = pgTable("courses", {
9 | id,
10 | name: text().notNull(),
11 | description: text().notNull(),
12 | createdAt,
13 | updatedAt,
14 | })
15 |
16 | export const CourseRelationships = relations(CourseTable, ({ many }) => ({
17 | courseProducts: many(CourseProductTable),
18 | userCourseAccesses: many(UserCourseAccessTable),
19 | courseSections: many(CourseSectionTable),
20 | }))
21 |
--------------------------------------------------------------------------------
/src/drizzle/schema/courseProduct.ts:
--------------------------------------------------------------------------------
1 | import { pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"
2 | import { CourseTable } from "./course"
3 | import { ProductTable } from "./product"
4 | import { createdAt, updatedAt } from "../schemaHelpers"
5 | import { relations } from "drizzle-orm"
6 |
7 | export const CourseProductTable = pgTable(
8 | "course_products",
9 | {
10 | courseId: uuid()
11 | .notNull()
12 | .references(() => CourseTable.id, { onDelete: "restrict" }),
13 | productId: uuid()
14 | .notNull()
15 | .references(() => ProductTable.id, { onDelete: "cascade" }),
16 | createdAt,
17 | updatedAt,
18 | },
19 | t => [primaryKey({ columns: [t.courseId, t.productId] })]
20 | )
21 |
22 | export const CourseProductRelationships = relations(
23 | CourseProductTable,
24 | ({ one }) => ({
25 | course: one(CourseTable, {
26 | fields: [CourseProductTable.courseId],
27 | references: [CourseTable.id],
28 | }),
29 | product: one(ProductTable, {
30 | fields: [CourseProductTable.productId],
31 | references: [ProductTable.id],
32 | }),
33 | })
34 | )
35 |
--------------------------------------------------------------------------------
/src/drizzle/schema/courseSection.ts:
--------------------------------------------------------------------------------
1 | import { integer, pgEnum, pgTable, text, uuid } from "drizzle-orm/pg-core"
2 | import { createdAt, id, updatedAt } from "../schemaHelpers"
3 | import { CourseTable } from "./course"
4 | import { relations } from "drizzle-orm"
5 | import { LessonTable } from "./lesson"
6 |
7 | export const courseSectionStatuses = ["public", "private"] as const
8 | export type CourseSectionStatus = (typeof courseSectionStatuses)[number]
9 | export const courseSectionStatusEnum = pgEnum(
10 | "course_section_status",
11 | courseSectionStatuses
12 | )
13 |
14 | export const CourseSectionTable = pgTable("course_sections", {
15 | id,
16 | name: text().notNull(),
17 | status: courseSectionStatusEnum().notNull().default("private"),
18 | order: integer().notNull(),
19 | courseId: uuid()
20 | .notNull()
21 | .references(() => CourseTable.id, { onDelete: "cascade" }),
22 | createdAt,
23 | updatedAt,
24 | })
25 |
26 | export const CourseSectionRelationships = relations(
27 | CourseSectionTable,
28 | ({ many, one }) => ({
29 | course: one(CourseTable, {
30 | fields: [CourseSectionTable.courseId],
31 | references: [CourseTable.id],
32 | }),
33 | lessons: many(LessonTable),
34 | })
35 | )
36 |
--------------------------------------------------------------------------------
/src/drizzle/schema/lesson.ts:
--------------------------------------------------------------------------------
1 | import { pgTable, text, uuid, integer, pgEnum } from "drizzle-orm/pg-core"
2 | import { createdAt, id, updatedAt } from "../schemaHelpers"
3 | import { relations } from "drizzle-orm"
4 | import { CourseSectionTable } from "./courseSection"
5 | import { UserLessonCompleteTable } from "./userLessonComplete"
6 |
7 | export const lessonStatuses = ["public", "private", "preview"] as const
8 | export type LessonStatus = (typeof lessonStatuses)[number]
9 | export const lessonStatusEnum = pgEnum("lesson_status", lessonStatuses)
10 |
11 | export const LessonTable = pgTable("lessons", {
12 | id,
13 | name: text().notNull(),
14 | description: text(),
15 | youtubeVideoId: text().notNull(),
16 | order: integer().notNull(),
17 | status: lessonStatusEnum().notNull().default("private"),
18 | sectionId: uuid()
19 | .notNull()
20 | .references(() => CourseSectionTable.id, { onDelete: "cascade" }),
21 | createdAt,
22 | updatedAt,
23 | })
24 |
25 | export const LessonRelationships = relations(LessonTable, ({ one, many }) => ({
26 | section: one(CourseSectionTable, {
27 | fields: [LessonTable.sectionId],
28 | references: [CourseSectionTable.id],
29 | }),
30 | userLessonsComplete: many(UserLessonCompleteTable),
31 | }))
32 |
--------------------------------------------------------------------------------
/src/drizzle/schema/product.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm"
2 | import { pgTable, text, integer, pgEnum } from "drizzle-orm/pg-core"
3 | import { createdAt, id, updatedAt } from "../schemaHelpers"
4 | import { CourseProductTable } from "./courseProduct"
5 |
6 | export const productStatuses = ["public", "private"] as const
7 | export type ProductStatus = (typeof productStatuses)[number]
8 | export const productStatusEnum = pgEnum("product_status", productStatuses)
9 |
10 | export const ProductTable = pgTable("products", {
11 | id,
12 | name: text().notNull(),
13 | description: text().notNull(),
14 | imageUrl: text().notNull(),
15 | priceInDollars: integer().notNull(),
16 | status: productStatusEnum().notNull().default("private"),
17 | createdAt,
18 | updatedAt,
19 | })
20 |
21 | export const ProductRelationships = relations(ProductTable, ({ many }) => ({
22 | courseProducts: many(CourseProductTable),
23 | }))
24 |
--------------------------------------------------------------------------------
/src/drizzle/schema/purchase.ts:
--------------------------------------------------------------------------------
1 | import {
2 | pgTable,
3 | integer,
4 | jsonb,
5 | uuid,
6 | text,
7 | timestamp,
8 | } from "drizzle-orm/pg-core"
9 | import { createdAt, id, updatedAt } from "../schemaHelpers"
10 | import { relations } from "drizzle-orm"
11 | import { UserTable } from "./user"
12 | import { ProductTable } from "./product"
13 |
14 | export const PurchaseTable = pgTable("purchases", {
15 | id,
16 | pricePaidInCents: integer().notNull(),
17 | productDetails: jsonb()
18 | .notNull()
19 | .$type<{ name: string; description: string; imageUrl: string }>(),
20 | userId: uuid()
21 | .notNull()
22 | .references(() => UserTable.id, { onDelete: "restrict" }),
23 | productId: uuid()
24 | .notNull()
25 | .references(() => ProductTable.id, { onDelete: "restrict" }),
26 | stripeSessionId: text().notNull().unique(),
27 | refundedAt: timestamp({ withTimezone: true }),
28 | createdAt,
29 | updatedAt,
30 | })
31 |
32 | export const PurchaseRelationships = relations(PurchaseTable, ({ one }) => ({
33 | user: one(UserTable, {
34 | fields: [PurchaseTable.userId],
35 | references: [UserTable.id],
36 | }),
37 | product: one(ProductTable, {
38 | fields: [PurchaseTable.productId],
39 | references: [ProductTable.id],
40 | }),
41 | }))
42 |
--------------------------------------------------------------------------------
/src/drizzle/schema/user.ts:
--------------------------------------------------------------------------------
1 | import { pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core"
2 | import { createdAt, id, updatedAt } from "../schemaHelpers"
3 | import { relations } from "drizzle-orm"
4 | import { UserCourseAccessTable } from "./userCourseAccess"
5 |
6 | export const userRoles = ["user", "admin"] as const
7 | export type UserRole = (typeof userRoles)[number]
8 | export const userRoleEnum = pgEnum("user_role", userRoles)
9 |
10 | export const UserTable = pgTable("users", {
11 | id,
12 | clerkUserId: text().notNull().unique(),
13 | email: text().notNull(),
14 | name: text().notNull(),
15 | role: userRoleEnum().notNull().default("user"),
16 | imageUrl: text(),
17 | deletedAt: timestamp({ withTimezone: true }),
18 | createdAt,
19 | updatedAt,
20 | })
21 |
22 | export const UserRelationships = relations(UserTable, ({ many }) => ({
23 | userCourseAccesses: many(UserCourseAccessTable),
24 | }))
25 |
--------------------------------------------------------------------------------
/src/drizzle/schema/userCourseAccess.ts:
--------------------------------------------------------------------------------
1 | import { pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"
2 | import { createdAt, updatedAt } from "../schemaHelpers"
3 | import { relations } from "drizzle-orm"
4 | import { UserTable } from "./user"
5 | import { CourseTable } from "./course"
6 |
7 | export const UserCourseAccessTable = pgTable(
8 | "user_course_access",
9 | {
10 | userId: uuid()
11 | .notNull()
12 | .references(() => UserTable.id, { onDelete: "cascade" }),
13 | courseId: uuid()
14 | .notNull()
15 | .references(() => CourseTable.id, { onDelete: "cascade" }),
16 | createdAt,
17 | updatedAt,
18 | },
19 | t => [primaryKey({ columns: [t.userId, t.courseId] })]
20 | )
21 |
22 | export const UserCourseAccessRelationships = relations(
23 | UserCourseAccessTable,
24 | ({ one }) => ({
25 | user: one(UserTable, {
26 | fields: [UserCourseAccessTable.userId],
27 | references: [UserTable.id],
28 | }),
29 | course: one(CourseTable, {
30 | fields: [UserCourseAccessTable.courseId],
31 | references: [CourseTable.id],
32 | }),
33 | })
34 | )
35 |
--------------------------------------------------------------------------------
/src/drizzle/schema/userLessonComplete.ts:
--------------------------------------------------------------------------------
1 | import { pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"
2 | import { createdAt, updatedAt } from "../schemaHelpers"
3 | import { relations } from "drizzle-orm"
4 | import { UserTable } from "./user"
5 | import { LessonTable } from "./lesson"
6 |
7 | export const UserLessonCompleteTable = pgTable(
8 | "user_lesson_complete",
9 | {
10 | userId: uuid()
11 | .notNull()
12 | .references(() => UserTable.id, { onDelete: "cascade" }),
13 | lessonId: uuid()
14 | .notNull()
15 | .references(() => LessonTable.id, { onDelete: "cascade" }),
16 | createdAt,
17 | updatedAt,
18 | },
19 | t => [primaryKey({ columns: [t.userId, t.lessonId] })]
20 | )
21 |
22 | export const UserLessonCompleteRelationships = relations(
23 | UserLessonCompleteTable,
24 | ({ one }) => ({
25 | user: one(UserTable, {
26 | fields: [UserLessonCompleteTable.userId],
27 | references: [UserTable.id],
28 | }),
29 | lesson: one(LessonTable, {
30 | fields: [UserLessonCompleteTable.lessonId],
31 | references: [LessonTable.id],
32 | }),
33 | })
34 | )
35 |
--------------------------------------------------------------------------------
/src/drizzle/schemaHelpers.ts:
--------------------------------------------------------------------------------
1 | import { timestamp, uuid } from "drizzle-orm/pg-core"
2 |
3 | export const id = uuid().primaryKey().defaultRandom()
4 | export const createdAt = timestamp({ withTimezone: true })
5 | .notNull()
6 | .defaultNow()
7 | export const updatedAt = timestamp({ withTimezone: true })
8 | .notNull()
9 | .defaultNow()
10 | .$onUpdate(() => new Date())
11 |
--------------------------------------------------------------------------------
/src/features/courseSections/actions/sections.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { z } from "zod"
4 | import { getCurrentUser } from "@/services/clerk"
5 | import { sectionSchema } from "../schemas/sections"
6 | import {
7 | canCreateCourseSections,
8 | canDeleteCourseSections,
9 | canUpdateCourseSections,
10 | } from "../permissions/sections"
11 | import {
12 | getNextCourseSectionOrder,
13 | insertSection,
14 | updateSection as updateSectionDb,
15 | deleteSection as deleteSectionDb,
16 | updateSectionOrders as updateSectionOrdersDb,
17 | } from "../db/sections"
18 |
19 | export async function createSection(
20 | courseId: string,
21 | unsafeData: z.infer
22 | ) {
23 | const { success, data } = sectionSchema.safeParse(unsafeData)
24 |
25 | if (!success || !canCreateCourseSections(await getCurrentUser())) {
26 | return { error: true, message: "There was an error creating your section" }
27 | }
28 |
29 | const order = await getNextCourseSectionOrder(courseId)
30 |
31 | await insertSection({ ...data, courseId, order })
32 |
33 | return { error: false, message: "Successfully created your section" }
34 | }
35 |
36 | export async function updateSection(
37 | id: string,
38 | unsafeData: z.infer
39 | ) {
40 | const { success, data } = sectionSchema.safeParse(unsafeData)
41 |
42 | if (!success || !canUpdateCourseSections(await getCurrentUser())) {
43 | return { error: true, message: "There was an error updating your section" }
44 | }
45 |
46 | await updateSectionDb(id, data)
47 |
48 | return { error: false, message: "Successfully updated your section" }
49 | }
50 |
51 | export async function deleteSection(id: string) {
52 | if (!canDeleteCourseSections(await getCurrentUser())) {
53 | return { error: true, message: "Error deleting your section" }
54 | }
55 |
56 | await deleteSectionDb(id)
57 |
58 | return { error: false, message: "Successfully deleted your section" }
59 | }
60 |
61 | export async function updateSectionOrders(sectionIds: string[]) {
62 | if (
63 | sectionIds.length === 0 ||
64 | !canUpdateCourseSections(await getCurrentUser())
65 | ) {
66 | return { error: true, message: "Error reordering your sections" }
67 | }
68 |
69 | await updateSectionOrdersDb(sectionIds)
70 |
71 | return { error: false, message: "Successfully reordered your sections" }
72 | }
73 |
--------------------------------------------------------------------------------
/src/features/courseSections/components/SectionForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useForm } from "react-hook-form"
4 | import { zodResolver } from "@hookform/resolvers/zod"
5 | import { sectionSchema } from "../schemas/sections"
6 | import { z } from "zod"
7 | import {
8 | Form,
9 | FormControl,
10 | FormField,
11 | FormItem,
12 | FormLabel,
13 | FormMessage,
14 | } from "@/components/ui/form"
15 | import { RequiredLabelIcon } from "@/components/RequiredLabelIcon"
16 | import { Input } from "@/components/ui/input"
17 | import { Button } from "@/components/ui/button"
18 | import { actionToast } from "@/hooks/use-toast"
19 | import { CourseSectionStatus, courseSectionStatuses } from "@/drizzle/schema"
20 | import {
21 | Select,
22 | SelectItem,
23 | SelectTrigger,
24 | SelectValue,
25 | SelectContent,
26 | } from "@/components/ui/select"
27 | import { createSection, updateSection } from "../actions/sections"
28 |
29 | export function SectionForm({
30 | section,
31 | courseId,
32 | onSuccess,
33 | }: {
34 | section?: {
35 | id: string
36 | name: string
37 | status: CourseSectionStatus
38 | }
39 | courseId: string
40 | onSuccess?: () => void
41 | }) {
42 | const form = useForm>({
43 | resolver: zodResolver(sectionSchema),
44 | defaultValues: section ?? {
45 | name: "",
46 | status: "public",
47 | },
48 | })
49 |
50 | async function onSubmit(values: z.infer) {
51 | const action =
52 | section == null
53 | ? createSection.bind(null, courseId)
54 | : updateSection.bind(null, section.id)
55 | const data = await action(values)
56 | actionToast({ actionData: data })
57 | if (!data.error) onSuccess?.()
58 | }
59 |
60 | return (
61 |
117 |
118 | )
119 | }
120 |
--------------------------------------------------------------------------------
/src/features/courseSections/components/SectionFormDialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Dialog,
5 | DialogHeader,
6 | DialogTitle,
7 | DialogContent,
8 | } from "@/components/ui/dialog"
9 | import { CourseSectionStatus } from "@/drizzle/schema"
10 | import { ReactNode, useState } from "react"
11 | import { SectionForm } from "./SectionForm"
12 |
13 | export function SectionFormDialog({
14 | courseId,
15 | section,
16 | children,
17 | }: {
18 | courseId: string
19 | children: ReactNode
20 | section?: { id: string; name: string; status: CourseSectionStatus }
21 | }) {
22 | const [isOpen, setIsOpen] = useState(false)
23 |
24 | return (
25 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/src/features/courseSections/components/SortableSectionList.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { SortableItem, SortableList } from "@/components/SortableList"
4 | import { CourseSectionStatus } from "@/drizzle/schema"
5 | import { cn } from "@/lib/utils"
6 | import { EyeClosed, Trash2Icon } from "lucide-react"
7 | import { SectionFormDialog } from "./SectionFormDialog"
8 | import { Button } from "@/components/ui/button"
9 | import { ActionButton } from "@/components/ActionButton"
10 | import { deleteSection, updateSectionOrders } from "../actions/sections"
11 | import { DialogTrigger } from "@/components/ui/dialog"
12 |
13 | export function SortableSectionList({
14 | courseId,
15 | sections,
16 | }: {
17 | courseId: string
18 | sections: {
19 | id: string
20 | name: string
21 | status: CourseSectionStatus
22 | }[]
23 | }) {
24 | return (
25 |
26 | {items =>
27 | items.map(section => (
28 |
33 |
39 | {section.status === "private" && }
40 | {section.name}
41 |
42 |
43 |
44 |
47 |
48 |
49 |
55 |
56 | Delete
57 |
58 |
59 | ))
60 | }
61 |
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/src/features/courseSections/db/cache.ts:
--------------------------------------------------------------------------------
1 | import { getCourseTag, getGlobalTag, getIdTag } from "@/lib/dataCache"
2 | import { revalidateTag } from "next/cache"
3 |
4 | export function getCourseSectionGlobalTag() {
5 | return getGlobalTag("courseSections")
6 | }
7 |
8 | export function getCourseSectionIdTag(id: string) {
9 | return getIdTag("courseSections", id)
10 | }
11 |
12 | export function getCourseSectionCourseTag(courseId: string) {
13 | return getCourseTag("courseSections", courseId)
14 | }
15 |
16 | export function revalidateCourseSectionCache({
17 | id,
18 | courseId,
19 | }: {
20 | id: string
21 | courseId: string
22 | }) {
23 | revalidateTag(getCourseSectionGlobalTag())
24 | revalidateTag(getCourseSectionIdTag(id))
25 | revalidateTag(getCourseSectionCourseTag(courseId))
26 | }
27 |
--------------------------------------------------------------------------------
/src/features/courseSections/db/sections.ts:
--------------------------------------------------------------------------------
1 | import { CourseSectionTable } from "@/drizzle/schema"
2 | import { revalidateCourseSectionCache } from "./cache"
3 | import { db } from "@/drizzle/db"
4 | import { eq } from "drizzle-orm"
5 |
6 | export async function getNextCourseSectionOrder(courseId: string) {
7 | const section = await db.query.CourseSectionTable.findFirst({
8 | columns: { order: true },
9 | where: ({ courseId: courseIdCol }, { eq }) => eq(courseIdCol, courseId),
10 | orderBy: ({ order }, { desc }) => desc(order),
11 | })
12 |
13 | return section ? section.order + 1 : 0
14 | }
15 |
16 | export async function insertSection(
17 | data: typeof CourseSectionTable.$inferInsert
18 | ) {
19 | const [newSection] = await db
20 | .insert(CourseSectionTable)
21 | .values(data)
22 | .returning()
23 | if (newSection == null) throw new Error("Failed to create section")
24 |
25 | revalidateCourseSectionCache({
26 | courseId: newSection.courseId,
27 | id: newSection.id,
28 | })
29 |
30 | return newSection
31 | }
32 |
33 | export async function updateSection(
34 | id: string,
35 | data: Partial
36 | ) {
37 | const [updatedSection] = await db
38 | .update(CourseSectionTable)
39 | .set(data)
40 | .where(eq(CourseSectionTable.id, id))
41 | .returning()
42 | if (updatedSection == null) throw new Error("Failed to update section")
43 |
44 | revalidateCourseSectionCache({
45 | courseId: updatedSection.courseId,
46 | id: updatedSection.id,
47 | })
48 |
49 | return updatedSection
50 | }
51 |
52 | export async function deleteSection(id: string) {
53 | const [deletedSection] = await db
54 | .delete(CourseSectionTable)
55 | .where(eq(CourseSectionTable.id, id))
56 | .returning()
57 | if (deletedSection == null) throw new Error("Failed to delete section")
58 |
59 | revalidateCourseSectionCache({
60 | courseId: deletedSection.courseId,
61 | id: deletedSection.id,
62 | })
63 |
64 | return deletedSection
65 | }
66 |
67 | export async function updateSectionOrders(sectionIds: string[]) {
68 | const sections = await Promise.all(
69 | sectionIds.map((id, index) =>
70 | db
71 | .update(CourseSectionTable)
72 | .set({ order: index })
73 | .where(eq(CourseSectionTable.id, id))
74 | .returning({
75 | courseId: CourseSectionTable.courseId,
76 | id: CourseSectionTable.id,
77 | })
78 | )
79 | )
80 |
81 | sections.flat().forEach(({ id, courseId }) => {
82 | revalidateCourseSectionCache({
83 | courseId,
84 | id,
85 | })
86 | })
87 | }
88 |
--------------------------------------------------------------------------------
/src/features/courseSections/permissions/sections.ts:
--------------------------------------------------------------------------------
1 | import { CourseSectionTable, UserRole } from "@/drizzle/schema"
2 | import { eq } from "drizzle-orm"
3 |
4 | export function canCreateCourseSections({
5 | role,
6 | }: {
7 | role: UserRole | undefined
8 | }) {
9 | return role === "admin"
10 | }
11 |
12 | export function canUpdateCourseSections({
13 | role,
14 | }: {
15 | role: UserRole | undefined
16 | }) {
17 | return role === "admin"
18 | }
19 |
20 | export function canDeleteCourseSections({
21 | role,
22 | }: {
23 | role: UserRole | undefined
24 | }) {
25 | return role === "admin"
26 | }
27 |
28 | export const wherePublicCourseSections = eq(CourseSectionTable.status, "public")
29 |
--------------------------------------------------------------------------------
/src/features/courseSections/schemas/sections.ts:
--------------------------------------------------------------------------------
1 | import { courseSectionStatuses } from "@/drizzle/schema"
2 | import { z } from "zod"
3 |
4 | export const sectionSchema = z.object({
5 | name: z.string().min(1, "Required"),
6 | status: z.enum(courseSectionStatuses),
7 | })
8 |
--------------------------------------------------------------------------------
/src/features/courses/actions/courses.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { z } from "zod"
4 | import { courseSchema } from "../schemas/courses"
5 | import { redirect } from "next/navigation"
6 | import { getCurrentUser } from "@/services/clerk"
7 | import {
8 | canCreateCourses,
9 | canDeleteCourses,
10 | canUpdateCourses,
11 | } from "../permissions/courses"
12 | import {
13 | insertCourse,
14 | deleteCourse as deleteCourseDB,
15 | updateCourse as updateCourseDb,
16 | } from "../db/courses"
17 |
18 | export async function createCourse(unsafeData: z.infer) {
19 | const { success, data } = courseSchema.safeParse(unsafeData)
20 |
21 | if (!success || !canCreateCourses(await getCurrentUser())) {
22 | return { error: true, message: "There was an error creating your course" }
23 | }
24 |
25 | const course = await insertCourse(data)
26 |
27 | redirect(`/admin/courses/${course.id}/edit`)
28 | }
29 |
30 | export async function updateCourse(
31 | id: string,
32 | unsafeData: z.infer
33 | ) {
34 | const { success, data } = courseSchema.safeParse(unsafeData)
35 |
36 | if (!success || !canUpdateCourses(await getCurrentUser())) {
37 | return { error: true, message: "There was an error updating your course" }
38 | }
39 |
40 | await updateCourseDb(id, data)
41 |
42 | return { error: false, message: "Successfully updated your course" }
43 | }
44 |
45 | export async function deleteCourse(id: string) {
46 | if (!canDeleteCourses(await getCurrentUser())) {
47 | return { error: true, message: "Error deleting your course" }
48 | }
49 |
50 | await deleteCourseDB(id)
51 |
52 | return { error: false, message: "Successfully deleted your course" }
53 | }
54 |
--------------------------------------------------------------------------------
/src/features/courses/components/CourseForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useForm } from "react-hook-form"
4 | import { zodResolver } from "@hookform/resolvers/zod"
5 | import { courseSchema } from "../schemas/courses"
6 | import { z } from "zod"
7 | import {
8 | Form,
9 | FormControl,
10 | FormField,
11 | FormItem,
12 | FormLabel,
13 | FormMessage,
14 | } from "@/components/ui/form"
15 | import { RequiredLabelIcon } from "@/components/RequiredLabelIcon"
16 | import { Input } from "@/components/ui/input"
17 | import { Textarea } from "@/components/ui/textarea"
18 | import { Button } from "@/components/ui/button"
19 | import { createCourse, updateCourse } from "../actions/courses"
20 | import { actionToast } from "@/hooks/use-toast"
21 |
22 | export function CourseForm({
23 | course,
24 | }: {
25 | course?: {
26 | id: string
27 | name: string
28 | description: string
29 | }
30 | }) {
31 | const form = useForm>({
32 | resolver: zodResolver(courseSchema),
33 | defaultValues: course ?? {
34 | name: "",
35 | description: "",
36 | },
37 | })
38 |
39 | async function onSubmit(values: z.infer) {
40 | const action =
41 | course == null ? createCourse : updateCourse.bind(null, course.id)
42 | const data = await action(values)
43 | actionToast({ actionData: data })
44 | }
45 |
46 | return (
47 |
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/src/features/courses/components/CourseTable.tsx:
--------------------------------------------------------------------------------
1 | import { ActionButton } from "@/components/ActionButton"
2 | import { Button } from "@/components/ui/button"
3 | import {
4 | Table,
5 | TableBody,
6 | TableCell,
7 | TableHead,
8 | TableHeader,
9 | TableRow,
10 | } from "@/components/ui/table"
11 | import { formatPlural } from "@/lib/formatters"
12 | import { Trash2Icon } from "lucide-react"
13 | import Link from "next/link"
14 | import { deleteCourse } from "../actions/courses"
15 |
16 | export function CourseTable({
17 | courses,
18 | }: {
19 | courses: {
20 | id: string
21 | name: string
22 | sectionsCount: number
23 | lessonsCount: number
24 | studentsCount: number
25 | }[]
26 | }) {
27 | return (
28 |
29 |
30 |
31 |
32 | {formatPlural(courses.length, {
33 | singular: "course",
34 | plural: "courses",
35 | })}
36 |
37 | Students
38 | Actions
39 |
40 |
41 |
42 | {courses.map(course => (
43 |
44 |
45 |
46 | {course.name}
47 |
48 | {formatPlural(course.sectionsCount, {
49 | singular: "section",
50 | plural: "sections",
51 | })}{" "}
52 | •{" "}
53 | {formatPlural(course.lessonsCount, {
54 | singular: "lesson",
55 | plural: "lessons",
56 | })}
57 |
58 |
59 |
60 | {course.studentsCount}
61 |
62 |
63 |
66 |
71 |
72 | Delete
73 |
74 |
75 |
76 |
77 | ))}
78 |
79 |
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/src/features/courses/db/cache/courses.ts:
--------------------------------------------------------------------------------
1 | import { getGlobalTag, getIdTag } from "@/lib/dataCache"
2 | import { revalidateTag } from "next/cache"
3 |
4 | export function getCourseGlobalTag() {
5 | return getGlobalTag("courses")
6 | }
7 |
8 | export function getCourseIdTag(id: string) {
9 | return getIdTag("courses", id)
10 | }
11 |
12 | export function revalidateCourseCache(id: string) {
13 | revalidateTag(getCourseGlobalTag())
14 | revalidateTag(getCourseIdTag(id))
15 | }
16 |
--------------------------------------------------------------------------------
/src/features/courses/db/cache/userCourseAccess.ts:
--------------------------------------------------------------------------------
1 | import { getGlobalTag, getIdTag, getUserTag } from "@/lib/dataCache"
2 | import { revalidateTag } from "next/cache"
3 |
4 | export function getUserCourseAccessGlobalTag() {
5 | return getGlobalTag("userCourseAccess")
6 | }
7 |
8 | export function getUserCourseAccessIdTag({
9 | courseId,
10 | userId,
11 | }: {
12 | courseId: string
13 | userId: string
14 | }) {
15 | return getIdTag("userCourseAccess", `course:${courseId}-user:${userId}`)
16 | }
17 |
18 | export function getUserCourseAccessUserTag(userId: string) {
19 | return getUserTag("userCourseAccess", userId)
20 | }
21 |
22 | export function revalidateUserCourseAccessCache({
23 | courseId,
24 | userId,
25 | }: {
26 | courseId: string
27 | userId: string
28 | }) {
29 | revalidateTag(getUserCourseAccessGlobalTag())
30 | revalidateTag(getUserCourseAccessIdTag({ courseId, userId }))
31 | revalidateTag(getUserCourseAccessUserTag(userId))
32 | }
33 |
--------------------------------------------------------------------------------
/src/features/courses/db/courses.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/drizzle/db"
2 | import { CourseTable } from "@/drizzle/schema"
3 | import { revalidateCourseCache } from "./cache/courses"
4 | import { eq } from "drizzle-orm"
5 |
6 | export async function insertCourse(data: typeof CourseTable.$inferInsert) {
7 | const [newCourse] = await db.insert(CourseTable).values(data).returning()
8 | if (newCourse == null) throw new Error("Failed to create course")
9 | revalidateCourseCache(newCourse.id)
10 |
11 | return newCourse
12 | }
13 |
14 | export async function updateCourse(
15 | id: string,
16 | data: typeof CourseTable.$inferInsert
17 | ) {
18 | const [updatedCourse] = await db
19 | .update(CourseTable)
20 | .set(data)
21 | .where(eq(CourseTable.id, id))
22 | .returning()
23 | if (updatedCourse == null) throw new Error("Failed to update course")
24 | revalidateCourseCache(updatedCourse.id)
25 |
26 | return updatedCourse
27 | }
28 |
29 | export async function deleteCourse(id: string) {
30 | const [deletedCourse] = await db
31 | .delete(CourseTable)
32 | .where(eq(CourseTable.id, id))
33 | .returning()
34 | if (deletedCourse == null) throw new Error("Failed to delete course")
35 | revalidateCourseCache(deletedCourse.id)
36 |
37 | return deletedCourse
38 | }
39 |
--------------------------------------------------------------------------------
/src/features/courses/db/userCourseAcccess.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/drizzle/db"
2 | import {
3 | ProductTable,
4 | PurchaseTable,
5 | UserCourseAccessTable,
6 | } from "@/drizzle/schema"
7 | import { revalidateUserCourseAccessCache } from "./cache/userCourseAccess"
8 | import { and, eq, inArray, isNull } from "drizzle-orm"
9 |
10 | export async function addUserCourseAccess(
11 | {
12 | userId,
13 | courseIds,
14 | }: {
15 | userId: string
16 | courseIds: string[]
17 | },
18 | trx: Omit = db
19 | ) {
20 | const accesses = await trx
21 | .insert(UserCourseAccessTable)
22 | .values(courseIds.map(courseId => ({ userId, courseId })))
23 | .onConflictDoNothing()
24 | .returning()
25 |
26 | accesses.forEach(revalidateUserCourseAccessCache)
27 |
28 | return accesses
29 | }
30 |
31 | export async function revokeUserCourseAccess(
32 | {
33 | userId,
34 | productId,
35 | }: {
36 | userId: string
37 | productId: string
38 | },
39 | trx: Omit = db
40 | ) {
41 | const validPurchases = await trx.query.PurchaseTable.findMany({
42 | where: and(
43 | eq(PurchaseTable.userId, userId),
44 | isNull(PurchaseTable.refundedAt)
45 | ),
46 | with: {
47 | product: {
48 | with: { courseProducts: { columns: { courseId: true } } },
49 | },
50 | },
51 | })
52 |
53 | const refundPurchase = await trx.query.ProductTable.findFirst({
54 | where: eq(ProductTable.id, productId),
55 | with: { courseProducts: { columns: { courseId: true } } },
56 | })
57 |
58 | if (refundPurchase == null) return
59 |
60 | const validCourseIds = validPurchases.flatMap(p =>
61 | p.product.courseProducts.map(cp => cp.courseId)
62 | )
63 |
64 | const removeCourseIds = refundPurchase.courseProducts
65 | .flatMap(cp => cp.courseId)
66 | .filter(courseId => !validCourseIds.includes(courseId))
67 |
68 | const revokedAccesses = await trx
69 | .delete(UserCourseAccessTable)
70 | .where(
71 | and(
72 | eq(UserCourseAccessTable.userId, userId),
73 | inArray(UserCourseAccessTable.courseId, removeCourseIds)
74 | )
75 | )
76 | .returning()
77 |
78 | revokedAccesses.forEach(revalidateUserCourseAccessCache)
79 |
80 | return revokedAccesses
81 | }
82 |
--------------------------------------------------------------------------------
/src/features/courses/permissions/courses.ts:
--------------------------------------------------------------------------------
1 | import { UserRole } from "@/drizzle/schema"
2 |
3 | export function canCreateCourses({ role }: { role: UserRole | undefined }) {
4 | return role === "admin"
5 | }
6 |
7 | export function canUpdateCourses({ role }: { role: UserRole | undefined }) {
8 | return role === "admin"
9 | }
10 |
11 | export function canDeleteCourses({ role }: { role: UserRole | undefined }) {
12 | return role === "admin"
13 | }
14 |
--------------------------------------------------------------------------------
/src/features/courses/schemas/courses.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod"
2 |
3 | export const courseSchema = z.object({
4 | name: z.string().min(1, "Required"),
5 | description: z.string().min(1, "Required"),
6 | })
7 |
--------------------------------------------------------------------------------
/src/features/lessons/actions/lessons.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { z } from "zod"
4 | import { lessonSchema } from "../schemas/lessons"
5 | import { getCurrentUser } from "@/services/clerk"
6 | import {
7 | canCreateLessons,
8 | canDeleteLessons,
9 | canUpdateLessons,
10 | } from "../permissions/lessons"
11 | import {
12 | getNextCourseLessonOrder,
13 | insertLesson,
14 | updateLesson as updateLessonDb,
15 | deleteLesson as deleteLessonDb,
16 | updateLessonOrders as updateLessonOrdersDb,
17 | } from "../db/lessons"
18 |
19 | export async function createLesson(unsafeData: z.infer) {
20 | const { success, data } = lessonSchema.safeParse(unsafeData)
21 |
22 | if (!success || !canCreateLessons(await getCurrentUser())) {
23 | return { error: true, message: "There was an error creating your lesson" }
24 | }
25 |
26 | const order = await getNextCourseLessonOrder(data.sectionId)
27 |
28 | await insertLesson({ ...data, order })
29 |
30 | return { error: false, message: "Successfully created your lesson" }
31 | }
32 |
33 | export async function updateLesson(
34 | id: string,
35 | unsafeData: z.infer
36 | ) {
37 | const { success, data } = lessonSchema.safeParse(unsafeData)
38 |
39 | if (!success || !canUpdateLessons(await getCurrentUser())) {
40 | return { error: true, message: "There was an error updating your lesson" }
41 | }
42 |
43 | await updateLessonDb(id, data)
44 |
45 | return { error: false, message: "Successfully updated your lesson" }
46 | }
47 |
48 | export async function deleteLesson(id: string) {
49 | if (!canDeleteLessons(await getCurrentUser())) {
50 | return { error: true, message: "Error deleting your lesson" }
51 | }
52 |
53 | await deleteLessonDb(id)
54 |
55 | return { error: false, message: "Successfully deleted your lesson" }
56 | }
57 |
58 | export async function updateLessonOrders(lessonIds: string[]) {
59 | if (lessonIds.length === 0 || !canUpdateLessons(await getCurrentUser())) {
60 | return { error: true, message: "Error reordering your lessons" }
61 | }
62 |
63 | await updateLessonOrdersDb(lessonIds)
64 |
65 | return { error: false, message: "Successfully reordered your lessons" }
66 | }
67 |
--------------------------------------------------------------------------------
/src/features/lessons/actions/userLessonComplete.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { getCurrentUser } from "@/services/clerk"
4 | import { canUpdateUserLessonCompleteStatus } from "../permissions/userLessonComplete"
5 | import { updateLessonCompleteStatus as updateLessonCompleteStatusDb } from "../db/userLessonComplete"
6 |
7 | export async function updateLessonCompleteStatus(
8 | lessonId: string,
9 | complete: boolean
10 | ) {
11 | const { userId } = await getCurrentUser()
12 |
13 | const hasPermission = await canUpdateUserLessonCompleteStatus(
14 | { userId },
15 | lessonId
16 | )
17 |
18 | if (userId == null || !hasPermission) {
19 | return { error: true, message: "Error updating lesson completion status" }
20 | }
21 |
22 | await updateLessonCompleteStatusDb({ lessonId, userId, complete })
23 |
24 | return {
25 | error: false,
26 | message: "Successfully updated lesson completion status",
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/features/lessons/components/LessonFormDialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Dialog,
5 | DialogHeader,
6 | DialogTitle,
7 | DialogContent,
8 | } from "@/components/ui/dialog"
9 | import { LessonStatus } from "@/drizzle/schema"
10 | import { ReactNode, useState } from "react"
11 | import { LessonForm } from "./LessonForm"
12 |
13 | export function LessonFormDialog({
14 | sections,
15 | defaultSectionId,
16 | lesson,
17 | children,
18 | }: {
19 | children: ReactNode
20 | sections: { id: string; name: string }[]
21 | defaultSectionId?: string
22 | lesson?: {
23 | id: string
24 | name: string
25 | status: LessonStatus
26 | youtubeVideoId: string
27 | description: string | null
28 | sectionId: string
29 | }
30 | }) {
31 | const [isOpen, setIsOpen] = useState(false)
32 |
33 | return (
34 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/src/features/lessons/components/SortableLessonList.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { SortableItem, SortableList } from "@/components/SortableList"
4 | import { LessonStatus } from "@/drizzle/schema"
5 | import { cn } from "@/lib/utils"
6 | import { EyeClosed, Trash2Icon, VideoIcon } from "lucide-react"
7 | import { Button } from "@/components/ui/button"
8 | import { ActionButton } from "@/components/ActionButton"
9 | import { DialogTrigger } from "@/components/ui/dialog"
10 | import { LessonFormDialog } from "./LessonFormDialog"
11 | import { deleteLesson, updateLessonOrders } from "../actions/lessons"
12 |
13 | export function SortableLessonList({
14 | sections,
15 | lessons,
16 | }: {
17 | sections: {
18 | id: string
19 | name: string
20 | }[]
21 | lessons: {
22 | id: string
23 | name: string
24 | status: LessonStatus
25 | youtubeVideoId: string
26 | description: string | null
27 | sectionId: string
28 | }[]
29 | }) {
30 | return (
31 |
32 | {items =>
33 | items.map(lesson => (
34 |
39 |
45 | {lesson.status === "private" && }
46 | {lesson.status === "preview" && }
47 | {lesson.name}
48 |
49 |
50 |
51 |
54 |
55 |
56 |
62 |
63 | Delete
64 |
65 |
66 | ))
67 | }
68 |
69 | )
70 | }
71 |
--------------------------------------------------------------------------------
/src/features/lessons/components/YouTubeVideoPlayer.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import YouTube from "react-youtube"
4 |
5 | export function YouTubeVideoPlayer({
6 | videoId,
7 | onFinishedVideo,
8 | }: {
9 | videoId: string
10 | onFinishedVideo?: () => void
11 | }) {
12 | return (
13 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/features/lessons/db/cache/lessons.ts:
--------------------------------------------------------------------------------
1 | import { getCourseTag, getGlobalTag, getIdTag } from "@/lib/dataCache"
2 | import { revalidateTag } from "next/cache"
3 |
4 | export function getLessonGlobalTag() {
5 | return getGlobalTag("lessons")
6 | }
7 |
8 | export function getLessonIdTag(id: string) {
9 | return getIdTag("lessons", id)
10 | }
11 |
12 | export function getLessonCourseTag(courseId: string) {
13 | return getCourseTag("lessons", courseId)
14 | }
15 |
16 | export function revalidateLessonCache({
17 | id,
18 | courseId,
19 | }: {
20 | id: string
21 | courseId: string
22 | }) {
23 | revalidateTag(getLessonGlobalTag())
24 | revalidateTag(getLessonIdTag(id))
25 | revalidateTag(getLessonCourseTag(courseId))
26 | }
27 |
--------------------------------------------------------------------------------
/src/features/lessons/db/cache/userLessonComplete.ts:
--------------------------------------------------------------------------------
1 | import { getGlobalTag, getIdTag, getUserTag } from "@/lib/dataCache"
2 | import { revalidateTag } from "next/cache"
3 |
4 | export function getUserLessonCompleteGlobalTag() {
5 | return getGlobalTag("userLessonComplete")
6 | }
7 |
8 | export function getUserLessonCompleteIdTag({
9 | lessonId,
10 | userId,
11 | }: {
12 | lessonId: string
13 | userId: string
14 | }) {
15 | return getIdTag("userLessonComplete", `lesson:${lessonId}-user:${userId}`)
16 | }
17 |
18 | export function getUserLessonCompleteUserTag(userId: string) {
19 | return getUserTag("userLessonComplete", userId)
20 | }
21 |
22 | export function revalidateUserLessonCompleteCache({
23 | lessonId,
24 | userId,
25 | }: {
26 | lessonId: string
27 | userId: string
28 | }) {
29 | revalidateTag(getUserLessonCompleteGlobalTag())
30 | revalidateTag(getUserLessonCompleteIdTag({ lessonId, userId }))
31 | revalidateTag(getUserLessonCompleteUserTag(userId))
32 | }
33 |
--------------------------------------------------------------------------------
/src/features/lessons/db/lessons.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/drizzle/db"
2 | import { CourseSectionTable, LessonTable } from "@/drizzle/schema"
3 | import { eq } from "drizzle-orm"
4 | import { revalidateLessonCache } from "./cache/lessons"
5 |
6 | export async function getNextCourseLessonOrder(sectionId: string) {
7 | const lesson = await db.query.LessonTable.findFirst({
8 | columns: { order: true },
9 | where: ({ sectionId: sectionIdCol }, { eq }) => eq(sectionIdCol, sectionId),
10 | orderBy: ({ order }, { desc }) => desc(order),
11 | })
12 |
13 | return lesson ? lesson.order + 1 : 0
14 | }
15 |
16 | export async function insertLesson(data: typeof LessonTable.$inferInsert) {
17 | const [newLesson, courseId] = await db.transaction(async trx => {
18 | const [[newLesson], section] = await Promise.all([
19 | trx.insert(LessonTable).values(data).returning(),
20 | trx.query.CourseSectionTable.findFirst({
21 | columns: { courseId: true },
22 | where: eq(CourseSectionTable.id, data.sectionId),
23 | }),
24 | ])
25 |
26 | if (section == null) return trx.rollback()
27 |
28 | return [newLesson, section.courseId]
29 | })
30 | if (newLesson == null) throw new Error("Failed to create lesson")
31 |
32 | revalidateLessonCache({ courseId, id: newLesson.id })
33 |
34 | return newLesson
35 | }
36 |
37 | export async function updateLesson(
38 | id: string,
39 | data: Partial
40 | ) {
41 | const [updatedLesson, courseId] = await db.transaction(async trx => {
42 | const currentLesson = await trx.query.LessonTable.findFirst({
43 | where: eq(LessonTable.id, id),
44 | columns: { sectionId: true },
45 | })
46 |
47 | if (
48 | data.sectionId != null &&
49 | currentLesson?.sectionId !== data.sectionId &&
50 | data.order == null
51 | ) {
52 | data.order = await getNextCourseLessonOrder(data.sectionId)
53 | }
54 |
55 | const [updatedLesson] = await trx
56 | .update(LessonTable)
57 | .set(data)
58 | .where(eq(LessonTable.id, id))
59 | .returning()
60 | if (updatedLesson == null) {
61 | trx.rollback()
62 | throw new Error("Failed to update lesson")
63 | }
64 |
65 | const section = await trx.query.CourseSectionTable.findFirst({
66 | columns: { courseId: true },
67 | where: eq(CourseSectionTable.id, updatedLesson.sectionId),
68 | })
69 |
70 | if (section == null) return trx.rollback()
71 |
72 | return [updatedLesson, section.courseId]
73 | })
74 |
75 | revalidateLessonCache({ courseId, id: updatedLesson.id })
76 |
77 | return updatedLesson
78 | }
79 |
80 | export async function deleteLesson(id: string) {
81 | const [deletedLesson, courseId] = await db.transaction(async trx => {
82 | const [deletedLesson] = await trx
83 | .delete(LessonTable)
84 | .where(eq(LessonTable.id, id))
85 | .returning()
86 | if (deletedLesson == null) {
87 | trx.rollback()
88 | throw new Error("Failed to delete lesson")
89 | }
90 |
91 | const section = await trx.query.CourseSectionTable.findFirst({
92 | columns: { courseId: true },
93 | where: ({ id }, { eq }) => eq(id, deletedLesson.sectionId),
94 | })
95 |
96 | if (section == null) return trx.rollback()
97 |
98 | return [deletedLesson, section.courseId]
99 | })
100 |
101 | revalidateLessonCache({
102 | id: deletedLesson.id,
103 | courseId,
104 | })
105 |
106 | return deletedLesson
107 | }
108 |
109 | export async function updateLessonOrders(lessonIds: string[]) {
110 | const [lessons, courseId] = await db.transaction(async trx => {
111 | const lessons = await Promise.all(
112 | lessonIds.map((id, index) =>
113 | db
114 | .update(LessonTable)
115 | .set({ order: index })
116 | .where(eq(LessonTable.id, id))
117 | .returning({
118 | sectionId: LessonTable.sectionId,
119 | id: LessonTable.id,
120 | })
121 | )
122 | )
123 | const sectionId = lessons[0]?.[0]?.sectionId
124 | if (sectionId == null) return trx.rollback()
125 |
126 | const section = await trx.query.CourseSectionTable.findFirst({
127 | columns: { courseId: true },
128 | where: ({ id }, { eq }) => eq(id, sectionId),
129 | })
130 |
131 | if (section == null) return trx.rollback()
132 |
133 | return [lessons, section.courseId]
134 | })
135 |
136 | lessons.flat().forEach(({ id }) => {
137 | revalidateLessonCache({
138 | courseId,
139 | id,
140 | })
141 | })
142 | }
143 |
--------------------------------------------------------------------------------
/src/features/lessons/db/userLessonComplete.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/drizzle/db"
2 | import { UserLessonCompleteTable } from "@/drizzle/schema"
3 | import { and, eq } from "drizzle-orm"
4 | import { revalidateUserLessonCompleteCache } from "./cache/userLessonComplete"
5 |
6 | export async function updateLessonCompleteStatus({
7 | lessonId,
8 | userId,
9 | complete,
10 | }: {
11 | lessonId: string
12 | userId: string
13 | complete: boolean
14 | }) {
15 | let completion: { lessonId: string; userId: string } | undefined
16 | if (complete) {
17 | const [c] = await db
18 | .insert(UserLessonCompleteTable)
19 | .values({
20 | lessonId,
21 | userId,
22 | })
23 | .onConflictDoNothing()
24 | .returning()
25 | completion = c
26 | } else {
27 | const [c] = await db
28 | .delete(UserLessonCompleteTable)
29 | .where(
30 | and(
31 | eq(UserLessonCompleteTable.lessonId, lessonId),
32 | eq(UserLessonCompleteTable.userId, userId)
33 | )
34 | )
35 | .returning()
36 | completion = c
37 | }
38 |
39 | if (completion == null) return
40 |
41 | revalidateUserLessonCompleteCache({
42 | lessonId: completion.lessonId,
43 | userId: completion.userId,
44 | })
45 |
46 | return completion
47 | }
48 |
--------------------------------------------------------------------------------
/src/features/lessons/permissions/lessons.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/drizzle/db"
2 | import {
3 | CourseSectionTable,
4 | CourseTable,
5 | LessonStatus,
6 | LessonTable,
7 | UserCourseAccessTable,
8 | UserRole,
9 | } from "@/drizzle/schema"
10 | import { getUserCourseAccessUserTag } from "@/features/courses/db/cache/userCourseAccess"
11 | import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections"
12 | import { and, eq, or } from "drizzle-orm"
13 | import { getLessonIdTag } from "../db/cache/lessons"
14 | import { cacheTag } from "next/dist/server/use-cache/cache-tag"
15 |
16 | export function canCreateLessons({ role }: { role: UserRole | undefined }) {
17 | return role === "admin"
18 | }
19 |
20 | export function canUpdateLessons({ role }: { role: UserRole | undefined }) {
21 | return role === "admin"
22 | }
23 |
24 | export function canDeleteLessons({ role }: { role: UserRole | undefined }) {
25 | return role === "admin"
26 | }
27 |
28 | export async function canViewLesson(
29 | {
30 | role,
31 | userId,
32 | }: {
33 | userId: string | undefined
34 | role: UserRole | undefined
35 | },
36 | lesson: { id: string; status: LessonStatus }
37 | ) {
38 | "use cache"
39 | if (role === "admin" || lesson.status === "preview") return true
40 | if (userId == null || lesson.status === "private") return false
41 |
42 | cacheTag(getUserCourseAccessUserTag(userId), getLessonIdTag(lesson.id))
43 |
44 | const [data] = await db
45 | .select({ courseId: CourseTable.id })
46 | .from(UserCourseAccessTable)
47 | .leftJoin(CourseTable, eq(CourseTable.id, UserCourseAccessTable.courseId))
48 | .leftJoin(
49 | CourseSectionTable,
50 | and(
51 | eq(CourseSectionTable.courseId, CourseTable.id),
52 | wherePublicCourseSections
53 | )
54 | )
55 | .leftJoin(
56 | LessonTable,
57 | and(eq(LessonTable.sectionId, CourseSectionTable.id), wherePublicLessons)
58 | )
59 | .where(
60 | and(
61 | eq(LessonTable.id, lesson.id),
62 | eq(UserCourseAccessTable.userId, userId)
63 | )
64 | )
65 | .limit(1)
66 |
67 | return data != null && data.courseId != null
68 | }
69 |
70 | export const wherePublicLessons = or(
71 | eq(LessonTable.status, "public"),
72 | eq(LessonTable.status, "preview")
73 | )
74 |
--------------------------------------------------------------------------------
/src/features/lessons/permissions/userLessonComplete.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/drizzle/db"
2 | import {
3 | CourseSectionTable,
4 | CourseTable,
5 | LessonTable,
6 | UserCourseAccessTable,
7 | } from "@/drizzle/schema"
8 | import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections"
9 | import { and, eq } from "drizzle-orm"
10 | import { wherePublicLessons } from "./lessons"
11 | import { cacheTag } from "next/dist/server/use-cache/cache-tag"
12 | import { getUserCourseAccessUserTag } from "@/features/courses/db/cache/userCourseAccess"
13 | import { getLessonIdTag } from "../db/cache/lessons"
14 |
15 | export async function canUpdateUserLessonCompleteStatus(
16 | user: { userId: string | undefined },
17 | lessonId: string
18 | ) {
19 | "use cache"
20 | cacheTag(getLessonIdTag(lessonId))
21 | if (user.userId == null) return false
22 |
23 | cacheTag(getUserCourseAccessUserTag(user.userId))
24 |
25 | const [courseAccess] = await db
26 | .select({ courseId: CourseTable.id })
27 | .from(UserCourseAccessTable)
28 | .innerJoin(CourseTable, eq(CourseTable.id, UserCourseAccessTable.courseId))
29 | .innerJoin(
30 | CourseSectionTable,
31 | and(
32 | eq(CourseSectionTable.courseId, CourseTable.id),
33 | wherePublicCourseSections
34 | )
35 | )
36 | .innerJoin(
37 | LessonTable,
38 | and(eq(LessonTable.sectionId, CourseSectionTable.id), wherePublicLessons)
39 | )
40 | .where(
41 | and(
42 | eq(LessonTable.id, lessonId),
43 | eq(UserCourseAccessTable.userId, user.userId)
44 | )
45 | )
46 | .limit(1)
47 |
48 | return courseAccess != null
49 | }
50 |
--------------------------------------------------------------------------------
/src/features/lessons/schemas/lessons.ts:
--------------------------------------------------------------------------------
1 | import { lessonStatusEnum } from "@/drizzle/schema"
2 | import { z } from "zod"
3 |
4 | export const lessonSchema = z.object({
5 | name: z.string().min(1, "Required"),
6 | sectionId: z.string().min(1, "Required"),
7 | status: z.enum(lessonStatusEnum.enumValues),
8 | youtubeVideoId: z.string().min(1, "Required"),
9 | description: z
10 | .string()
11 | .transform(v => (v === "" ? null : v))
12 | .nullable(),
13 | })
14 |
--------------------------------------------------------------------------------
/src/features/products/actions/products.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { z } from "zod"
4 | import {
5 | insertProduct,
6 | updateProduct as updateProductDb,
7 | deleteProduct as deleteProductDb,
8 | } from "@/features/products/db/products"
9 | import { redirect } from "next/navigation"
10 | import {
11 | canCreateProducts,
12 | canDeleteProducts,
13 | canUpdateProducts,
14 | } from "../permissions/products"
15 | import { getCurrentUser } from "@/services/clerk"
16 | import { productSchema } from "../schema/products"
17 |
18 | export async function createProduct(unsafeData: z.infer) {
19 | const { success, data } = productSchema.safeParse(unsafeData)
20 |
21 | if (!success || !canCreateProducts(await getCurrentUser())) {
22 | return { error: true, message: "There was an error creating your product" }
23 | }
24 |
25 | await insertProduct(data)
26 |
27 | redirect("/admin/products")
28 | }
29 |
30 | export async function updateProduct(
31 | id: string,
32 | unsafeData: z.infer
33 | ) {
34 | const { success, data } = productSchema.safeParse(unsafeData)
35 |
36 | if (!success || !canUpdateProducts(await getCurrentUser())) {
37 | return { error: true, message: "There was an error updating your product" }
38 | }
39 |
40 | await updateProductDb(id, data)
41 |
42 | redirect("/admin/products")
43 | }
44 |
45 | export async function deleteProduct(id: string) {
46 | if (!canDeleteProducts(await getCurrentUser())) {
47 | return { error: true, message: "Error deleting your product" }
48 | }
49 |
50 | await deleteProductDb(id)
51 |
52 | return { error: false, message: "Successfully deleted your product" }
53 | }
54 |
--------------------------------------------------------------------------------
/src/features/products/components/ProductCard.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardFooter,
7 | CardHeader,
8 | CardTitle,
9 | } from "@/components/ui/card"
10 | import { formatPrice } from "@/lib/formatters"
11 | import { getUserCoupon } from "@/lib/userCountryHeader"
12 | import Image from "next/image"
13 | import Link from "next/link"
14 | import { Suspense } from "react"
15 |
16 | export function ProductCard({
17 | id,
18 | imageUrl,
19 | name,
20 | priceInDollars,
21 | description,
22 | }: {
23 | id: string
24 | imageUrl: string
25 | name: string
26 | priceInDollars: number
27 | description: string
28 | }) {
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | {name}
41 |
42 |
43 | {description}
44 |
45 |
46 |
49 |
50 |
51 | )
52 | }
53 |
54 | async function Price({ price }: { price: number }) {
55 | const coupon = await getUserCoupon()
56 | if (price === 0 || coupon == null) {
57 | return formatPrice(price)
58 | }
59 |
60 | return (
61 |
62 |
63 | {formatPrice(price)}
64 |
65 | {formatPrice(price * (1 - coupon.discountPercentage))}
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/src/features/products/components/ProductTable.tsx:
--------------------------------------------------------------------------------
1 | import { ActionButton } from "@/components/ActionButton"
2 | import { Badge } from "@/components/ui/badge"
3 | import { Button } from "@/components/ui/button"
4 | import {
5 | Table,
6 | TableBody,
7 | TableCell,
8 | TableHead,
9 | TableHeader,
10 | TableRow,
11 | } from "@/components/ui/table"
12 | import { ProductStatus } from "@/drizzle/schema"
13 | import { formatPlural, formatPrice } from "@/lib/formatters"
14 | import { EyeIcon, LockIcon, Trash2Icon } from "lucide-react"
15 | import Image from "next/image"
16 | import Link from "next/link"
17 | import { deleteProduct } from "../actions/products"
18 |
19 | export function ProductTable({
20 | products,
21 | }: {
22 | products: {
23 | id: string
24 | name: string
25 | description: string
26 | imageUrl: string
27 | priceInDollars: number
28 | status: ProductStatus
29 | coursesCount: number
30 | customersCount: number
31 | }[]
32 | }) {
33 | return (
34 |
35 |
36 |
37 |
38 | {formatPlural(products.length, {
39 | singular: "product",
40 | plural: "products",
41 | })}
42 |
43 | Customers
44 | Status
45 | Actions
46 |
47 |
48 |
49 | {products.map(product => (
50 |
51 |
52 |
53 |
60 |
61 | {product.name}
62 |
63 | {formatPlural(product.coursesCount, {
64 | singular: "course",
65 | plural: "courses",
66 | })}{" "}
67 | • {formatPrice(product.priceInDollars)}
68 |
69 |
70 |
71 |
72 | {product.customersCount}
73 |
74 |
75 | {getStatusIcon(product.status)} {product.status}
76 |
77 |
78 |
79 |
80 |
83 |
88 |
89 | Delete
90 |
91 |
92 |
93 |
94 | ))}
95 |
96 |
97 | )
98 | }
99 |
100 | function getStatusIcon(status: ProductStatus) {
101 | const Icon = {
102 | public: EyeIcon,
103 | private: LockIcon,
104 | }[status]
105 |
106 | return
107 | }
108 |
--------------------------------------------------------------------------------
/src/features/products/db/cache.ts:
--------------------------------------------------------------------------------
1 | import { getGlobalTag, getIdTag } from "@/lib/dataCache"
2 | import { revalidateTag } from "next/cache"
3 |
4 | export function getProductGlobalTag() {
5 | return getGlobalTag("products")
6 | }
7 |
8 | export function getProductIdTag(id: string) {
9 | return getIdTag("products", id)
10 | }
11 |
12 | export function revalidateProductCache(id: string) {
13 | revalidateTag(getProductGlobalTag())
14 | revalidateTag(getProductIdTag(id))
15 | }
16 |
--------------------------------------------------------------------------------
/src/features/products/db/products.ts:
--------------------------------------------------------------------------------
1 | import { and, eq, isNull } from "drizzle-orm"
2 | import { db } from "@/drizzle/db"
3 | import { revalidateProductCache } from "./cache"
4 | import {
5 | CourseProductTable,
6 | ProductTable,
7 | PurchaseTable,
8 | } from "@/drizzle/schema"
9 | import { cacheTag } from "next/dist/server/use-cache/cache-tag"
10 | import { getPurchaseUserTag } from "@/features/purchases/db/cache"
11 |
12 | export async function userOwnsProduct({
13 | userId,
14 | productId,
15 | }: {
16 | userId: string
17 | productId: string
18 | }) {
19 | "use cache"
20 | cacheTag(getPurchaseUserTag(userId))
21 |
22 | const existingPurchase = await db.query.PurchaseTable.findFirst({
23 | where: and(
24 | eq(PurchaseTable.productId, productId),
25 | eq(PurchaseTable.userId, userId),
26 | isNull(PurchaseTable.refundedAt)
27 | ),
28 | })
29 |
30 | return existingPurchase != null
31 | }
32 |
33 | export async function insertProduct(
34 | data: typeof ProductTable.$inferInsert & { courseIds: string[] }
35 | ) {
36 | const newProduct = await db.transaction(async trx => {
37 | const [newProduct] = await trx.insert(ProductTable).values(data).returning()
38 | if (newProduct == null) {
39 | trx.rollback()
40 | throw new Error("Failed to create product")
41 | }
42 |
43 | await trx.insert(CourseProductTable).values(
44 | data.courseIds.map(courseId => ({
45 | productId: newProduct.id,
46 | courseId,
47 | }))
48 | )
49 |
50 | return newProduct
51 | })
52 |
53 | revalidateProductCache(newProduct.id)
54 |
55 | return newProduct
56 | }
57 |
58 | export async function updateProduct(
59 | id: string,
60 | data: Partial & { courseIds: string[] }
61 | ) {
62 | const updatedProduct = await db.transaction(async trx => {
63 | const [updatedProduct] = await trx
64 | .update(ProductTable)
65 | .set(data)
66 | .where(eq(ProductTable.id, id))
67 | .returning()
68 | if (updatedProduct == null) {
69 | trx.rollback()
70 | throw new Error("Failed to create product")
71 | }
72 |
73 | await trx
74 | .delete(CourseProductTable)
75 | .where(eq(CourseProductTable.productId, updatedProduct.id))
76 |
77 | await trx.insert(CourseProductTable).values(
78 | data.courseIds.map(courseId => ({
79 | productId: updatedProduct.id,
80 | courseId,
81 | }))
82 | )
83 |
84 | return updatedProduct
85 | })
86 |
87 | revalidateProductCache(updatedProduct.id)
88 |
89 | return updatedProduct
90 | }
91 |
92 | export async function deleteProduct(id: string) {
93 | const [deletedProduct] = await db
94 | .delete(ProductTable)
95 | .where(eq(ProductTable.id, id))
96 | .returning()
97 | if (deletedProduct == null) throw new Error("Failed to delete product")
98 |
99 | revalidateProductCache(deletedProduct.id)
100 |
101 | return deletedProduct
102 | }
103 |
--------------------------------------------------------------------------------
/src/features/products/permissions/products.ts:
--------------------------------------------------------------------------------
1 | import { ProductTable, UserRole } from "@/drizzle/schema"
2 | import { eq } from "drizzle-orm"
3 |
4 | export function canCreateProducts({ role }: { role: UserRole | undefined }) {
5 | return role === "admin"
6 | }
7 |
8 | export function canUpdateProducts({ role }: { role: UserRole | undefined }) {
9 | return role === "admin"
10 | }
11 |
12 | export function canDeleteProducts({ role }: { role: UserRole | undefined }) {
13 | return role === "admin"
14 | }
15 |
16 | export const wherePublicProducts = eq(ProductTable.status, "public")
17 |
--------------------------------------------------------------------------------
/src/features/products/schema/products.ts:
--------------------------------------------------------------------------------
1 | import { productStatuses } from "@/drizzle/schema"
2 | import { z } from "zod"
3 |
4 | export const productSchema = z.object({
5 | name: z.string().min(1, "Required"),
6 | priceInDollars: z.number().int().nonnegative(),
7 | description: z.string().min(1, "Required"),
8 | imageUrl: z.union([
9 | z.string().url("Invalid url"),
10 | z.string().startsWith("/", "Invalid url"),
11 | ]),
12 | status: z.enum(productStatuses),
13 | courseIds: z.array(z.string()).min(1, "At least one course is required"),
14 | })
15 |
--------------------------------------------------------------------------------
/src/features/purchases/actions/purchases.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { stripeServerClient } from "@/services/stripe/stripeServer"
4 | import { canRefundPurchases } from "../permissions/products"
5 | import { getCurrentUser } from "@/services/clerk"
6 | import { db } from "@/drizzle/db"
7 | import { updatePurchase } from "../db/purchases"
8 | import { revokeUserCourseAccess } from "@/features/courses/db/userCourseAcccess"
9 |
10 | export async function refundPurchase(id: string) {
11 | if (!canRefundPurchases(await getCurrentUser())) {
12 | return {
13 | error: true,
14 | message: "There was an error refunding this purchase",
15 | }
16 | }
17 |
18 | const data = await db.transaction(async trx => {
19 | const refundedPurchase = await updatePurchase(
20 | id,
21 | { refundedAt: new Date() },
22 | trx
23 | )
24 |
25 | const session = await stripeServerClient.checkout.sessions.retrieve(
26 | refundedPurchase.stripeSessionId
27 | )
28 |
29 | if (session.payment_intent == null) {
30 | trx.rollback()
31 | return {
32 | error: true,
33 | message: "There was an error refunding this purchase",
34 | }
35 | }
36 |
37 | try {
38 | await stripeServerClient.refunds.create({
39 | payment_intent:
40 | typeof session.payment_intent === "string"
41 | ? session.payment_intent
42 | : session.payment_intent.id,
43 | })
44 | await revokeUserCourseAccess(refundedPurchase, trx)
45 | } catch {
46 | trx.rollback()
47 | return {
48 | error: true,
49 | message: "There was an error refunding this purchase",
50 | }
51 | }
52 | })
53 |
54 | return data ?? { error: false, message: "Successfully refunded purchase" }
55 | }
56 |
--------------------------------------------------------------------------------
/src/features/purchases/components/PurchaseTable.tsx:
--------------------------------------------------------------------------------
1 | import { ActionButton } from "@/components/ActionButton"
2 | import {
3 | SkeletonArray,
4 | SkeletonButton,
5 | SkeletonText,
6 | } from "@/components/Skeleton"
7 | import { Badge } from "@/components/ui/badge"
8 | import {
9 | Table,
10 | TableBody,
11 | TableCell,
12 | TableHead,
13 | TableHeader,
14 | TableRow,
15 | } from "@/components/ui/table"
16 | import { formatDate, formatPlural, formatPrice } from "@/lib/formatters"
17 | import Image from "next/image"
18 | import { refundPurchase } from "../actions/purchases"
19 |
20 | export function PurchaseTable({
21 | purchases,
22 | }: {
23 | purchases: {
24 | id: string
25 | pricePaidInCents: number
26 | createdAt: Date
27 | refundedAt: Date | null
28 | productDetails: {
29 | name: string
30 | imageUrl: string
31 | }
32 | user: {
33 | name: string
34 | }
35 | }[]
36 | }) {
37 | return (
38 |
39 |
40 |
41 |
42 | {" "}
43 | {formatPlural(purchases.length, {
44 | singular: "sale",
45 | plural: "sales",
46 | })}
47 |
48 | Customer Name
49 | Amount
50 | Actions
51 |
52 |
53 |
54 | {purchases.map(purchase => (
55 |
56 |
57 |
58 |
65 |
66 |
67 | {purchase.productDetails.name}
68 |
69 |
70 | {formatDate(purchase.createdAt)}
71 |
72 |
73 |
74 |
75 | {purchase.user.name}
76 |
77 | {purchase.refundedAt ? (
78 | Refunded
79 | ) : (
80 | formatPrice(purchase.pricePaidInCents / 100)
81 | )}
82 |
83 |
84 | {purchase.refundedAt == null && purchase.pricePaidInCents > 0 && (
85 |
90 | Refund
91 |
92 | )}
93 |
94 |
95 | ))}
96 |
97 |
98 | )
99 | }
100 |
101 | export function UserPurchaseTableSkeleton() {
102 | return (
103 |
104 |
105 |
106 | Product
107 | Amount
108 | Actions
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 | )
134 | }
135 |
--------------------------------------------------------------------------------
/src/features/purchases/components/UserPurchaseTable.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | SkeletonArray,
3 | SkeletonButton,
4 | SkeletonText,
5 | } from "@/components/Skeleton"
6 | import { Badge } from "@/components/ui/badge"
7 | import { Button } from "@/components/ui/button"
8 | import {
9 | Table,
10 | TableBody,
11 | TableCell,
12 | TableHead,
13 | TableHeader,
14 | TableRow,
15 | } from "@/components/ui/table"
16 | import { formatDate, formatPrice } from "@/lib/formatters"
17 | import Image from "next/image"
18 | import Link from "next/link"
19 |
20 | export function UserPurchaseTable({
21 | purchases,
22 | }: {
23 | purchases: {
24 | id: string
25 | pricePaidInCents: number
26 | createdAt: Date
27 | refundedAt: Date | null
28 | productDetails: {
29 | name: string
30 | imageUrl: string
31 | }
32 | }[]
33 | }) {
34 | return (
35 |
36 |
37 |
38 | Product
39 | Amount
40 | Actions
41 |
42 |
43 |
44 | {purchases.map(purchase => (
45 |
46 |
47 |
48 |
55 |
56 |
57 | {purchase.productDetails.name}
58 |
59 |
60 | {formatDate(purchase.createdAt)}
61 |
62 |
63 |
64 |
65 |
66 | {purchase.refundedAt ? (
67 | Refunded
68 | ) : (
69 | formatPrice(purchase.pricePaidInCents / 100)
70 | )}
71 |
72 |
73 |
76 |
77 |
78 | ))}
79 |
80 |
81 | )
82 | }
83 |
84 | export function UserPurchaseTableSkeleton() {
85 | return (
86 |
87 |
88 |
89 | Product
90 | Amount
91 | Actions
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | )
117 | }
118 |
--------------------------------------------------------------------------------
/src/features/purchases/db/cache.ts:
--------------------------------------------------------------------------------
1 | import { getGlobalTag, getIdTag, getUserTag } from "@/lib/dataCache"
2 | import { revalidateTag } from "next/cache"
3 |
4 | export function getPurchaseGlobalTag() {
5 | return getGlobalTag("purchases")
6 | }
7 |
8 | export function getPurchaseIdTag(id: string) {
9 | return getIdTag("purchases", id)
10 | }
11 |
12 | export function getPurchaseUserTag(userId: string) {
13 | return getUserTag("purchases", userId)
14 | }
15 |
16 | export function revalidatePurchaseCache({
17 | id,
18 | userId,
19 | }: {
20 | id: string
21 | userId: string
22 | }) {
23 | revalidateTag(getPurchaseGlobalTag())
24 | revalidateTag(getPurchaseIdTag(id))
25 | revalidateTag(getPurchaseUserTag(userId))
26 | }
27 |
--------------------------------------------------------------------------------
/src/features/purchases/db/purchases.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/drizzle/db"
2 | import { PurchaseTable } from "@/drizzle/schema"
3 | import { revalidatePurchaseCache } from "./cache"
4 | import { eq } from "drizzle-orm"
5 |
6 | export async function insertPurchase(
7 | data: typeof PurchaseTable.$inferInsert,
8 | trx: Omit = db
9 | ) {
10 | const details = data.productDetails
11 |
12 | const [newPurchase] = await trx
13 | .insert(PurchaseTable)
14 | .values({
15 | ...data,
16 | productDetails: {
17 | name: details.name,
18 | description: details.description,
19 | imageUrl: details.imageUrl,
20 | },
21 | })
22 | .onConflictDoNothing()
23 | .returning()
24 |
25 | if (newPurchase != null) revalidatePurchaseCache(newPurchase)
26 |
27 | return newPurchase
28 | }
29 |
30 | export async function updatePurchase(
31 | id: string,
32 | data: Partial,
33 | trx: Omit = db
34 | ) {
35 | const details = data.productDetails
36 |
37 | const [updatedPurchase] = await trx
38 | .update(PurchaseTable)
39 | .set({
40 | ...data,
41 | productDetails: details
42 | ? {
43 | name: details.name,
44 | description: details.description,
45 | imageUrl: details.imageUrl,
46 | }
47 | : undefined,
48 | })
49 | .where(eq(PurchaseTable.id, id))
50 | .returning()
51 | if (updatedPurchase == null) throw new Error("Failed to update purchase")
52 |
53 | revalidatePurchaseCache(updatedPurchase)
54 |
55 | return updatedPurchase
56 | }
57 |
--------------------------------------------------------------------------------
/src/features/purchases/permissions/products.ts:
--------------------------------------------------------------------------------
1 | import { UserRole } from "@/drizzle/schema"
2 |
3 | export function canRefundPurchases({ role }: { role: UserRole | undefined }) {
4 | return role === "admin"
5 | }
6 |
--------------------------------------------------------------------------------
/src/features/users/db/cache.ts:
--------------------------------------------------------------------------------
1 | import { getGlobalTag, getIdTag } from "@/lib/dataCache"
2 | import { revalidateTag } from "next/cache"
3 |
4 | export function getUserGlobalTag() {
5 | return getGlobalTag("users")
6 | }
7 |
8 | export function getUserIdTag(id: string) {
9 | return getIdTag("users", id)
10 | }
11 |
12 | export function revalidateUserCache(id: string) {
13 | revalidateTag(getUserGlobalTag())
14 | revalidateTag(getUserIdTag(id))
15 | }
16 |
--------------------------------------------------------------------------------
/src/features/users/db/users.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/drizzle/db"
2 | import { UserTable } from "@/drizzle/schema"
3 | import { eq } from "drizzle-orm"
4 | import { revalidateUserCache } from "./cache"
5 |
6 | export async function insertUser(data: typeof UserTable.$inferInsert) {
7 | const [newUser] = await db
8 | .insert(UserTable)
9 | .values(data)
10 | .returning()
11 | .onConflictDoUpdate({
12 | target: [UserTable.clerkUserId],
13 | set: data,
14 | })
15 |
16 | if (newUser == null) throw new Error("Failed to create user")
17 | revalidateUserCache(newUser.id)
18 |
19 | return newUser
20 | }
21 |
22 | export async function updateUser(
23 | { clerkUserId }: { clerkUserId: string },
24 | data: Partial
25 | ) {
26 | const [updatedUser] = await db
27 | .update(UserTable)
28 | .set(data)
29 | .where(eq(UserTable.clerkUserId, clerkUserId))
30 | .returning()
31 |
32 | if (updatedUser == null) throw new Error("Failed to update user")
33 | revalidateUserCache(updatedUser.id)
34 |
35 | return updatedUser
36 | }
37 |
38 | export async function deleteUser({ clerkUserId }: { clerkUserId: string }) {
39 | const [deletedUser] = await db
40 | .update(UserTable)
41 | .set({
42 | deletedAt: new Date(),
43 | email: "redacted@deleted.com",
44 | name: "Deleted User",
45 | clerkUserId: "deleted",
46 | imageUrl: null,
47 | })
48 | .where(eq(UserTable.clerkUserId, clerkUserId))
49 | .returning()
50 |
51 | if (deletedUser == null) throw new Error("Failed to delete user")
52 | revalidateUserCache(deletedUser.id)
53 |
54 | return deletedUser
55 | }
56 |
--------------------------------------------------------------------------------
/src/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // Inspired by react-hot-toast library
4 | import * as React from "react"
5 |
6 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
7 |
8 | const TOAST_LIMIT = 1
9 | const TOAST_REMOVE_DELAY = 1000000
10 |
11 | type ToasterToast = ToastProps & {
12 | id: string
13 | title?: React.ReactNode
14 | description?: React.ReactNode
15 | action?: ToastActionElement
16 | }
17 |
18 | let count = 0
19 |
20 | function genId() {
21 | count = (count + 1) % Number.MAX_SAFE_INTEGER
22 | return count.toString()
23 | }
24 |
25 | type ActionType = {
26 | ADD_TOAST: "ADD_TOAST"
27 | UPDATE_TOAST: "UPDATE_TOAST"
28 | DISMISS_TOAST: "DISMISS_TOAST"
29 | REMOVE_TOAST: "REMOVE_TOAST"
30 | }
31 |
32 | type Action =
33 | | {
34 | type: ActionType["ADD_TOAST"]
35 | toast: ToasterToast
36 | }
37 | | {
38 | type: ActionType["UPDATE_TOAST"]
39 | toast: Partial
40 | }
41 | | {
42 | type: ActionType["DISMISS_TOAST"]
43 | toastId?: ToasterToast["id"]
44 | }
45 | | {
46 | type: ActionType["REMOVE_TOAST"]
47 | toastId?: ToasterToast["id"]
48 | }
49 |
50 | interface State {
51 | toasts: ToasterToast[]
52 | }
53 |
54 | const toastTimeouts = new Map>()
55 |
56 | const addToRemoveQueue = (toastId: string) => {
57 | if (toastTimeouts.has(toastId)) {
58 | return
59 | }
60 |
61 | const timeout = setTimeout(() => {
62 | toastTimeouts.delete(toastId)
63 | dispatch({
64 | type: "REMOVE_TOAST",
65 | toastId: toastId,
66 | })
67 | }, TOAST_REMOVE_DELAY)
68 |
69 | toastTimeouts.set(toastId, timeout)
70 | }
71 |
72 | export const reducer = (state: State, action: Action): State => {
73 | switch (action.type) {
74 | case "ADD_TOAST":
75 | return {
76 | ...state,
77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
78 | }
79 |
80 | case "UPDATE_TOAST":
81 | return {
82 | ...state,
83 | toasts: state.toasts.map(t =>
84 | t.id === action.toast.id ? { ...t, ...action.toast } : t
85 | ),
86 | }
87 |
88 | case "DISMISS_TOAST": {
89 | const { toastId } = action
90 |
91 | // ! Side effects ! - This could be extracted into a dismissToast() action,
92 | // but I'll keep it here for simplicity
93 | if (toastId) {
94 | addToRemoveQueue(toastId)
95 | } else {
96 | state.toasts.forEach(toast => {
97 | addToRemoveQueue(toast.id)
98 | })
99 | }
100 |
101 | return {
102 | ...state,
103 | toasts: state.toasts.map(t =>
104 | t.id === toastId || toastId === undefined
105 | ? {
106 | ...t,
107 | open: false,
108 | }
109 | : t
110 | ),
111 | }
112 | }
113 | case "REMOVE_TOAST":
114 | if (action.toastId === undefined) {
115 | return {
116 | ...state,
117 | toasts: [],
118 | }
119 | }
120 | return {
121 | ...state,
122 | toasts: state.toasts.filter(t => t.id !== action.toastId),
123 | }
124 | }
125 | }
126 |
127 | const listeners: Array<(state: State) => void> = []
128 |
129 | let memoryState: State = { toasts: [] }
130 |
131 | function dispatch(action: Action) {
132 | memoryState = reducer(memoryState, action)
133 | listeners.forEach(listener => {
134 | listener(memoryState)
135 | })
136 | }
137 |
138 | type Toast = Omit
139 |
140 | function toast({ ...props }: Toast) {
141 | const id = genId()
142 |
143 | const update = (props: ToasterToast) =>
144 | dispatch({
145 | type: "UPDATE_TOAST",
146 | toast: { ...props, id },
147 | })
148 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
149 |
150 | dispatch({
151 | type: "ADD_TOAST",
152 | toast: {
153 | ...props,
154 | id,
155 | open: true,
156 | onOpenChange: open => {
157 | if (!open) dismiss()
158 | },
159 | },
160 | })
161 |
162 | return {
163 | id: id,
164 | dismiss,
165 | update,
166 | }
167 | }
168 |
169 | function useToast() {
170 | const [state, setState] = React.useState(memoryState)
171 |
172 | React.useEffect(() => {
173 | listeners.push(setState)
174 | return () => {
175 | const index = listeners.indexOf(setState)
176 | if (index > -1) {
177 | listeners.splice(index, 1)
178 | }
179 | }
180 | }, [state])
181 |
182 | return {
183 | ...state,
184 | toast,
185 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
186 | }
187 | }
188 |
189 | function actionToast({
190 | actionData,
191 | ...props
192 | }: Omit & {
193 | actionData: { error: boolean; message: string }
194 | }) {
195 | return toast({
196 | ...props,
197 | title: actionData.error ? "Error" : "Success",
198 | description: actionData.message,
199 | variant: actionData.error ? "destructive" : "default",
200 | })
201 | }
202 |
203 | export { useToast, toast, actionToast }
204 |
--------------------------------------------------------------------------------
/src/lib/dataCache.ts:
--------------------------------------------------------------------------------
1 | type CACHE_TAG =
2 | | "products"
3 | | "users"
4 | | "courses"
5 | | "userCourseAccess"
6 | | "courseSections"
7 | | "lessons"
8 | | "purchases"
9 | | "userLessonComplete"
10 |
11 | export function getGlobalTag(tag: CACHE_TAG) {
12 | return `global:${tag}` as const
13 | }
14 |
15 | export function getIdTag(tag: CACHE_TAG, id: string) {
16 | return `id:${id}-${tag}` as const
17 | }
18 |
19 | export function getUserTag(tag: CACHE_TAG, userId: string) {
20 | return `user:${userId}-${tag}` as const
21 | }
22 |
23 | export function getCourseTag(tag: CACHE_TAG, courseId: string) {
24 | return `course:${courseId}-${tag}` as const
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/formatters.ts:
--------------------------------------------------------------------------------
1 | export function formatPlural(
2 | count: number,
3 | { singular, plural }: { singular: string; plural: string },
4 | { includeCount = true } = {}
5 | ) {
6 | const word = count === 1 ? singular : plural
7 |
8 | return includeCount ? `${count} ${word}` : word
9 | }
10 |
11 | export function formatPrice(amount: number, { showZeroAsNumber = false } = {}) {
12 | const formatter = new Intl.NumberFormat(undefined, {
13 | style: "currency",
14 | currency: "USD",
15 | minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
16 | })
17 |
18 | if (amount === 0 && !showZeroAsNumber) return "Free"
19 | return formatter.format(amount)
20 | }
21 |
22 | const DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
23 | dateStyle: "medium",
24 | timeStyle: "short",
25 | })
26 |
27 | export function formatDate(date: Date) {
28 | return DATE_FORMATTER.format(date)
29 | }
30 |
31 | export function formatNumber(
32 | number: number,
33 | options?: Intl.NumberFormatOptions
34 | ) {
35 | const formatter = new Intl.NumberFormat(undefined, options)
36 | return formatter.format(number)
37 | }
38 |
--------------------------------------------------------------------------------
/src/lib/sumArray.ts:
--------------------------------------------------------------------------------
1 | export function sumArray(array: T[], func: (item: T) => number) {
2 | return array.reduce((acc, item) => acc + func(item), 0)
3 | }
4 |
--------------------------------------------------------------------------------
/src/lib/userCountryHeader.ts:
--------------------------------------------------------------------------------
1 | import { pppCoupons } from "@/data/pppCoupons"
2 | import { headers } from "next/headers"
3 |
4 | const COUNTRY_HEADER_KEY = "x-user-country"
5 |
6 | export function setUserCountryHeader(
7 | headers: Headers,
8 | country: string | undefined
9 | ) {
10 | if (country == null) {
11 | headers.delete(COUNTRY_HEADER_KEY)
12 | } else {
13 | headers.set(COUNTRY_HEADER_KEY, country)
14 | }
15 | }
16 |
17 | async function getUserCountry() {
18 | const head = await headers()
19 | return head.get(COUNTRY_HEADER_KEY)
20 | }
21 |
22 | export async function getUserCoupon() {
23 | const country = await getUserCountry()
24 | if (country == null) return
25 |
26 | const coupon = pppCoupons.find(coupon =>
27 | coupon.countryCodes.includes(country)
28 | )
29 |
30 | if (coupon == null) return
31 |
32 | return {
33 | stripeCouponId: coupon.stripeCouponId,
34 | discountPercentage: coupon.discountPercentage,
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"
2 | import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/next"
3 | import { env } from "./data/env/server"
4 | import { setUserCountryHeader } from "./lib/userCountryHeader"
5 | import { NextResponse } from "next/server"
6 |
7 | const isPublicRoute = createRouteMatcher([
8 | "/",
9 | "/sign-in(.*)",
10 | "/sign-up(.*)",
11 | "/api(.*)",
12 | "/courses/:courseId/lessons/:lessonId",
13 | "/products(.*)",
14 | ])
15 |
16 | const isAdminRoute = createRouteMatcher(["/admin(.*)"])
17 |
18 | const aj = arcjet({
19 | key: env.ARCJET_KEY,
20 | rules: [
21 | shield({ mode: "LIVE" }),
22 | detectBot({
23 | mode: "LIVE",
24 | allow: ["CATEGORY:SEARCH_ENGINE", "CATEGORY:MONITOR", "CATEGORY:PREVIEW"],
25 | }),
26 | slidingWindow({
27 | mode: "LIVE",
28 | interval: "1m",
29 | max: 100,
30 | }),
31 | ],
32 | })
33 |
34 | export default clerkMiddleware(async (auth, req) => {
35 | const decision = await aj.protect(
36 | env.TEST_IP_ADDRESS
37 | ? { ...req, ip: env.TEST_IP_ADDRESS, headers: req.headers }
38 | : req
39 | )
40 |
41 | if (decision.isDenied()) {
42 | return new NextResponse(null, { status: 403 })
43 | }
44 |
45 | if (isAdminRoute(req)) {
46 | const user = await auth.protect()
47 | if (user.sessionClaims.role !== "admin") {
48 | return new NextResponse(null, { status: 404 })
49 | }
50 | }
51 |
52 | if (!isPublicRoute(req)) {
53 | await auth.protect()
54 | }
55 |
56 | if (!decision.ip.isVpn() && !decision.ip.isProxy()) {
57 | const headers = new Headers(req.headers)
58 | setUserCountryHeader(headers, decision.ip.country)
59 |
60 | return NextResponse.next({ request: { headers } })
61 | }
62 | })
63 |
64 | export const config = {
65 | matcher: [
66 | // Skip Next.js internals and all static files, unless found in search params
67 | "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
68 | // Always run for API routes
69 | "/(api|trpc)(.*)",
70 | ],
71 | }
72 |
--------------------------------------------------------------------------------
/src/permissions/general.ts:
--------------------------------------------------------------------------------
1 | import { UserRole } from "@/drizzle/schema"
2 |
3 | export function canAccessAdminPages({ role }: { role: UserRole | undefined }) {
4 | return role === "admin"
5 | }
6 |
--------------------------------------------------------------------------------
/src/services/clerk.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/drizzle/db"
2 | import { UserRole, UserTable } from "@/drizzle/schema"
3 | import { getUserIdTag } from "@/features/users/db/cache"
4 | import { auth, clerkClient } from "@clerk/nextjs/server"
5 | import { eq } from "drizzle-orm"
6 | import { cacheTag } from "next/dist/server/use-cache/cache-tag"
7 | import { redirect } from "next/navigation"
8 |
9 | const client = await clerkClient()
10 |
11 | export async function getCurrentUser({ allData = false } = {}) {
12 | const { userId, sessionClaims, redirectToSignIn } = await auth()
13 |
14 | if (userId != null && sessionClaims.dbId == null) {
15 | redirect("/api/clerk/syncUsers")
16 | }
17 |
18 | return {
19 | clerkUserId: userId,
20 | userId: sessionClaims?.dbId,
21 | role: sessionClaims?.role,
22 | user:
23 | allData && sessionClaims?.dbId != null
24 | ? await getUser(sessionClaims.dbId)
25 | : undefined,
26 | redirectToSignIn,
27 | }
28 | }
29 |
30 | export function syncClerkUserMetadata(user: {
31 | id: string
32 | clerkUserId: string
33 | role: UserRole
34 | }) {
35 | return client.users.updateUserMetadata(user.clerkUserId, {
36 | publicMetadata: {
37 | dbId: user.id,
38 | role: user.role,
39 | },
40 | })
41 | }
42 |
43 | async function getUser(id: string) {
44 | "use cache"
45 | cacheTag(getUserIdTag(id))
46 | console.log("Called")
47 |
48 | return db.query.UserTable.findFirst({
49 | where: eq(UserTable.id, id),
50 | })
51 | }
52 |
--------------------------------------------------------------------------------
/src/services/stripe/actions/stripe.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { getUserCoupon } from "@/lib/userCountryHeader"
4 | import { stripeServerClient } from "../stripeServer"
5 | import { env } from "@/data/env/client"
6 |
7 | export async function getClientSessionSecret(
8 | product: {
9 | priceInDollars: number
10 | name: string
11 | imageUrl: string
12 | description: string
13 | id: string
14 | },
15 | user: { email: string; id: string }
16 | ) {
17 | const coupon = await getUserCoupon()
18 | const discounts = coupon ? [{ coupon: coupon.stripeCouponId }] : undefined
19 |
20 | const session = await stripeServerClient.checkout.sessions.create({
21 | line_items: [
22 | {
23 | quantity: 1,
24 | price_data: {
25 | currency: "usd",
26 | product_data: {
27 | name: product.name,
28 | images: [
29 | new URL(product.imageUrl, env.NEXT_PUBLIC_SERVER_URL).href,
30 | ],
31 | description: product.description,
32 | },
33 | unit_amount: product.priceInDollars * 100,
34 | },
35 | },
36 | ],
37 | ui_mode: "embedded",
38 | mode: "payment",
39 | return_url: `${env.NEXT_PUBLIC_SERVER_URL}/api/webhooks/stripe?stripeSessionId={CHECKOUT_SESSION_ID}`,
40 | customer_email: user.email,
41 | payment_intent_data: {
42 | receipt_email: user.email,
43 | },
44 | discounts,
45 | metadata: {
46 | productId: product.id,
47 | userId: user.id,
48 | },
49 | })
50 |
51 | if (session.client_secret == null) throw new Error("Client secret is null")
52 |
53 | return session.client_secret
54 | }
55 |
--------------------------------------------------------------------------------
/src/services/stripe/components/StripeCheckoutForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | EmbeddedCheckoutProvider,
5 | EmbeddedCheckout,
6 | } from "@stripe/react-stripe-js"
7 | import { stripeClientPromise } from "../stripeClient"
8 | import { getClientSessionSecret } from "../actions/stripe"
9 |
10 | export function StripeCheckoutForm({
11 | product,
12 | user,
13 | }: {
14 | product: {
15 | priceInDollars: number
16 | name: string
17 | id: string
18 | imageUrl: string
19 | description: string
20 | }
21 | user: {
22 | email: string
23 | id: string
24 | }
25 | }) {
26 | return (
27 |
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/src/services/stripe/stripeClient.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/data/env/client"
2 | import { loadStripe } from "@stripe/stripe-js"
3 |
4 | export const stripeClientPromise = loadStripe(
5 | env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
6 | )
7 |
--------------------------------------------------------------------------------
/src/services/stripe/stripeServer.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/data/env/server"
2 | import Stripe from "stripe"
3 |
4 | export const stripeServerClient = new Stripe(env.STRIPE_SECRET_KEY)
5 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 | import containerQueries from "@tailwindcss/container-queries"
3 |
4 | export default {
5 | darkMode: ["class"],
6 | content: [
7 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
9 | "./src/features/**/components/**/*.{js,ts,jsx,tsx,mdx}",
10 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
11 | ],
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: '2rem',
16 | screens: {
17 | sm: '1500px'
18 | }
19 | },
20 | extend: {
21 | colors: {
22 | background: 'hsl(var(--background))',
23 | foreground: 'hsl(var(--foreground))',
24 | card: {
25 | DEFAULT: 'hsl(var(--card))',
26 | foreground: 'hsl(var(--card-foreground))'
27 | },
28 | popover: {
29 | DEFAULT: 'hsl(var(--popover))',
30 | foreground: 'hsl(var(--popover-foreground))'
31 | },
32 | primary: {
33 | DEFAULT: 'hsl(var(--primary))',
34 | foreground: 'hsl(var(--primary-foreground))'
35 | },
36 | secondary: {
37 | DEFAULT: 'hsl(var(--secondary))',
38 | foreground: 'hsl(var(--secondary-foreground))'
39 | },
40 | muted: {
41 | DEFAULT: 'hsl(var(--muted))',
42 | foreground: 'hsl(var(--muted-foreground))'
43 | },
44 | accent: {
45 | DEFAULT: 'hsl(var(--accent))',
46 | foreground: 'hsl(var(--accent-foreground))'
47 | },
48 | destructive: {
49 | DEFAULT: 'hsl(var(--destructive))',
50 | foreground: 'hsl(var(--destructive-foreground))'
51 | },
52 | border: 'hsl(var(--border))',
53 | input: 'hsl(var(--input))',
54 | ring: 'hsl(var(--ring))',
55 | chart: {
56 | '1': 'hsl(var(--chart-1))',
57 | '2': 'hsl(var(--chart-2))',
58 | '3': 'hsl(var(--chart-3))',
59 | '4': 'hsl(var(--chart-4))',
60 | '5': 'hsl(var(--chart-5))'
61 | }
62 | },
63 | borderRadius: {
64 | lg: 'var(--radius)',
65 | md: 'calc(var(--radius) - 2px)',
66 | sm: 'calc(var(--radius) - 4px)'
67 | },
68 | keyframes: {
69 | 'accordion-down': {
70 | from: {
71 | height: '0'
72 | },
73 | to: {
74 | height: 'var(--radix-accordion-content-height)'
75 | }
76 | },
77 | 'accordion-up': {
78 | from: {
79 | height: 'var(--radix-accordion-content-height)'
80 | },
81 | to: {
82 | height: '0'
83 | }
84 | }
85 | },
86 | animation: {
87 | 'accordion-down': 'accordion-down 0.2s ease-out',
88 | 'accordion-up': 'accordion-up 0.2s ease-out'
89 | }
90 | }
91 | },
92 | plugins: [require("tailwindcss-animate"), containerQueries],
93 | } satisfies Config
94 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "noUncheckedIndexedAccess": true,
4 | "target": "ES2017",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "bundler",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
|