>(({ className, ...props }, ref) => (
| [role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
================================================
FILE: src/components/ui/tabs.tsx
================================================
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
================================================
FILE: src/components/ui/textarea.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
)
})
Textarea.displayName = "Textarea"
export { Textarea }
================================================
FILE: src/components/ui/toast.tsx
================================================
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"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",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef &
VariantProps
>(({ className, variant, ...props }, ref) => {
return (
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef
type ToastActionElement = React.ReactElement
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
================================================
FILE: src/components/ui/toaster.tsx
================================================
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
{title && {title}}
{description && (
{description}
)}
{action}
)
})}
)
}
================================================
FILE: src/data/env/client.ts
================================================
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"
export const env = createEnv({
client: {
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
NEXT_PUBLIC_CLERK_SIGN_IN_URL: z.string().min(1),
NEXT_PUBLIC_CLERK_SIGN_UP_URL: z.string().min(1),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
NEXT_PUBLIC_SERVER_URL: z.string().min(1),
},
experimental__runtimeEnv: {
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
NEXT_PUBLIC_CLERK_SIGN_IN_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL,
NEXT_PUBLIC_CLERK_SIGN_UP_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
NEXT_PUBLIC_SERVER_URL: process.env.NEXT_PUBLIC_SERVER_URL,
},
})
================================================
FILE: src/data/env/server.ts
================================================
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"
export const env = createEnv({
server: {
DB_PASSWORD: z.string().min(1),
DB_USER: z.string().min(1),
DB_NAME: z.string().min(1),
DB_HOST: z.string().min(1),
CLERK_SECRET_KEY: z.string().min(1),
CLERK_WEBHOOK_SECRET: z.string().min(1),
ARCJET_KEY: z.string().min(1),
TEST_IP_ADDRESS: z.string().min(1).optional(),
STRIPE_PPP_50_COUPON_ID: z.string().min(1),
STRIPE_PPP_40_COUPON_ID: z.string().min(1),
STRIPE_PPP_30_COUPON_ID: z.string().min(1),
STRIPE_PPP_20_COUPON_ID: z.string().min(1),
STRIPE_SECRET_KEY: z.string().min(1),
STRIPE_WEBHOOK_SECRET: z.string().min(1),
},
experimental__runtimeEnv: process.env,
})
================================================
FILE: src/data/pppCoupons.ts
================================================
import { env } from "./env/server"
export const pppCoupons = [
{
stripeCouponId: env.STRIPE_PPP_50_COUPON_ID,
discountPercentage: 0.5,
countryCodes: [
"AF",
"EG",
"IR",
"KG",
"LK",
"BT",
"LA",
"LB",
"LY",
"MM",
"PK",
"SL",
"TJ",
"NP",
"UZ",
"SD",
"IN",
"MG",
"TR",
"AL",
"BA",
"CM",
"BD",
"BF",
"BJ",
"JO",
"BI",
"CO",
"CI",
"FJ",
"ET",
"GE",
"KM",
"LS",
"KH",
"AM",
"BO",
"BY",
"DZ",
"ER",
"GH",
"GM",
"GW",
"ID",
"KE",
"KZ",
"MD",
"MK",
"ML",
"MW",
"MY",
"MZ",
"NG",
"NI",
"PH",
"PY",
"RW",
"TH",
"TZ",
"UA",
"UG",
"VN",
"MN",
"MR",
"MU",
"SO",
"TN",
"ZM",
"ME",
"RO",
"RS",
"SN",
"MA",
"NE",
"SR",
"SZ",
"TG",
"EC",
"BG",
"HR",
"BW",
"AO",
"AZ",
"CF",
"CV",
"GY",
"HU",
"GQ",
"HN",
"BH",
"CD",
"DO",
"GN",
"LR",
"PA",
"NA",
"PE",
"PL",
"SC",
"SV",
"TW",
"MV",
"TD",
"YE",
"ZA",
"RU",
],
},
{
stripeCouponId: env.STRIPE_PPP_40_COUPON_ID,
discountPercentage: 0.4,
countryCodes: [
"GR",
"KN",
"AR",
"BR",
"CN",
"DJ",
"IQ",
"JM",
"GT",
"LT",
"CL",
"CR",
"CZ",
"GA",
"GD",
"HT",
"LV",
"ST",
"VC",
"PT",
"MX",
"SA",
"SI",
"SK",
"TM",
"BN",
"MO",
"TL",
],
},
{
stripeCouponId: env.STRIPE_PPP_30_COUPON_ID,
discountPercentage: 0.3,
countryCodes: [
"AE",
"ES",
"AW",
"CY",
"EE",
"IT",
"KR",
"BZ",
"CG",
"MT",
"SG",
"DM",
"TO",
"VE",
"WS",
"OM",
"ZW",
],
},
{
stripeCouponId: env.STRIPE_PPP_20_COUPON_ID,
discountPercentage: 0.2,
countryCodes: [
"AT",
"JP",
"BE",
"BS",
"DE",
"FR",
"KI",
"KW",
"HK",
"LC",
"AG",
"QA",
"PG",
"TT",
"UY",
],
},
]
================================================
FILE: src/data/typeOverrides/clerk.d.ts
================================================
import { UserRole } from "@/drizzle/schema"
export {}
declare global {
interface CustomJwtSessionClaims {
dbId?: string
role?: UserRole
}
interface UserPublicMetadata {
dbId?: string
role?: UserRole
}
}
================================================
FILE: src/drizzle/db.ts
================================================
import { env } from "@/data/env/server"
import { drizzle } from "drizzle-orm/node-postgres"
import * as schema from "./schema"
export const db = drizzle({
schema,
connection: {
password: env.DB_PASSWORD,
user: env.DB_USER,
database: env.DB_NAME,
host: env.DB_HOST,
},
})
================================================
FILE: src/drizzle/migrations/0000_orange_wind_dancer.sql
================================================
CREATE TYPE "public"."course_section_status" AS ENUM('public', 'private');--> statement-breakpoint
CREATE TYPE "public"."lesson_status" AS ENUM('public', 'private', 'preview');--> statement-breakpoint
CREATE TYPE "public"."product_status" AS ENUM('public', 'private');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('user', 'admin');--> statement-breakpoint
CREATE TABLE "courses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "course_products" (
"courseId" uuid NOT NULL,
"productId" uuid NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "course_products_courseId_productId_pk" PRIMARY KEY("courseId","productId")
);
--> statement-breakpoint
CREATE TABLE "course_sections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"status" "course_section_status" DEFAULT 'private' NOT NULL,
"order" integer NOT NULL,
"courseId" uuid NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lessons" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"youtubeVideoId" text NOT NULL,
"order" integer NOT NULL,
"status" "lesson_status" DEFAULT 'private' NOT NULL,
"sectionId" uuid NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text NOT NULL,
"imageUrl" text NOT NULL,
"priceInDollars" integer NOT NULL,
"status" "product_status" DEFAULT 'private' NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "purchases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pricePaidInCents" integer NOT NULL,
"productDetails" jsonb NOT NULL,
"userId" uuid NOT NULL,
"productId" uuid NOT NULL,
"stripeSessionId" text NOT NULL,
"refundedAt" timestamp with time zone,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "purchases_stripeSessionId_unique" UNIQUE("stripeSessionId")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"clerkUserId" text NOT NULL,
"email" text NOT NULL,
"name" text NOT NULL,
"role" "user_role" DEFAULT 'user' NOT NULL,
"imageUrl" text,
"deletedAt" timestamp with time zone,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_clerkUserId_unique" UNIQUE("clerkUserId")
);
--> statement-breakpoint
CREATE TABLE "user_course_access" (
"userId" uuid NOT NULL,
"courseId" uuid NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "user_course_access_userId_courseId_pk" PRIMARY KEY("userId","courseId")
);
--> statement-breakpoint
CREATE TABLE "user_lesson_complete" (
"userId" uuid NOT NULL,
"lessonId" uuid NOT NULL,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "user_lesson_complete_userId_lessonId_pk" PRIMARY KEY("userId","lessonId")
);
--> statement-breakpoint
ALTER TABLE "course_products" ADD CONSTRAINT "course_products_courseId_courses_id_fk" FOREIGN KEY ("courseId") REFERENCES "public"."courses"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_products" ADD CONSTRAINT "course_products_productId_products_id_fk" FOREIGN KEY ("productId") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_sections" ADD CONSTRAINT "course_sections_courseId_courses_id_fk" FOREIGN KEY ("courseId") REFERENCES "public"."courses"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lessons" ADD CONSTRAINT "lessons_sectionId_course_sections_id_fk" FOREIGN KEY ("sectionId") REFERENCES "public"."course_sections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "purchases" ADD CONSTRAINT "purchases_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "purchases" ADD CONSTRAINT "purchases_productId_products_id_fk" FOREIGN KEY ("productId") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_course_access" ADD CONSTRAINT "user_course_access_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_course_access" ADD CONSTRAINT "user_course_access_courseId_courses_id_fk" FOREIGN KEY ("courseId") REFERENCES "public"."courses"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_lesson_complete" ADD CONSTRAINT "user_lesson_complete_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_lesson_complete" ADD CONSTRAINT "user_lesson_complete_lessonId_lessons_id_fk" FOREIGN KEY ("lessonId") REFERENCES "public"."lessons"("id") ON DELETE cascade ON UPDATE no action;
================================================
FILE: src/drizzle/migrations/meta/0000_snapshot.json
================================================
{
"id": "d4730870-f365-4597-857f-fb8168440798",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.courses": {
"name": "courses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.course_products": {
"name": "course_products",
"schema": "",
"columns": {
"courseId": {
"name": "courseId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"productId": {
"name": "productId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"course_products_courseId_courses_id_fk": {
"name": "course_products_courseId_courses_id_fk",
"tableFrom": "course_products",
"tableTo": "courses",
"columnsFrom": [
"courseId"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"course_products_productId_products_id_fk": {
"name": "course_products_productId_products_id_fk",
"tableFrom": "course_products",
"tableTo": "products",
"columnsFrom": [
"productId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"course_products_courseId_productId_pk": {
"name": "course_products_courseId_productId_pk",
"columns": [
"courseId",
"productId"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.course_sections": {
"name": "course_sections",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "course_section_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'private'"
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"courseId": {
"name": "courseId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"course_sections_courseId_courses_id_fk": {
"name": "course_sections_courseId_courses_id_fk",
"tableFrom": "course_sections",
"tableTo": "courses",
"columnsFrom": [
"courseId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lessons": {
"name": "lessons",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"youtubeVideoId": {
"name": "youtubeVideoId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "lesson_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'private'"
},
"sectionId": {
"name": "sectionId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"lessons_sectionId_course_sections_id_fk": {
"name": "lessons_sectionId_course_sections_id_fk",
"tableFrom": "lessons",
"tableTo": "course_sections",
"columnsFrom": [
"sectionId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.products": {
"name": "products",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"imageUrl": {
"name": "imageUrl",
"type": "text",
"primaryKey": false,
"notNull": true
},
"priceInDollars": {
"name": "priceInDollars",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "product_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'private'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.purchases": {
"name": "purchases",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"pricePaidInCents": {
"name": "pricePaidInCents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"productDetails": {
"name": "productDetails",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"productId": {
"name": "productId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"stripeSessionId": {
"name": "stripeSessionId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refundedAt": {
"name": "refundedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"purchases_userId_users_id_fk": {
"name": "purchases_userId_users_id_fk",
"tableFrom": "purchases",
"tableTo": "users",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"purchases_productId_products_id_fk": {
"name": "purchases_productId_products_id_fk",
"tableFrom": "purchases",
"tableTo": "products",
"columnsFrom": [
"productId"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"purchases_stripeSessionId_unique": {
"name": "purchases_stripeSessionId_unique",
"nullsNotDistinct": false,
"columns": [
"stripeSessionId"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"clerkUserId": {
"name": "clerkUserId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"imageUrl": {
"name": "imageUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"deletedAt": {
"name": "deletedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_clerkUserId_unique": {
"name": "users_clerkUserId_unique",
"nullsNotDistinct": false,
"columns": [
"clerkUserId"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_course_access": {
"name": "user_course_access",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"courseId": {
"name": "courseId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"user_course_access_userId_users_id_fk": {
"name": "user_course_access_userId_users_id_fk",
"tableFrom": "user_course_access",
"tableTo": "users",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_course_access_courseId_courses_id_fk": {
"name": "user_course_access_courseId_courses_id_fk",
"tableFrom": "user_course_access",
"tableTo": "courses",
"columnsFrom": [
"courseId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_course_access_userId_courseId_pk": {
"name": "user_course_access_userId_courseId_pk",
"columns": [
"userId",
"courseId"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_lesson_complete": {
"name": "user_lesson_complete",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"lessonId": {
"name": "lessonId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"user_lesson_complete_userId_users_id_fk": {
"name": "user_lesson_complete_userId_users_id_fk",
"tableFrom": "user_lesson_complete",
"tableTo": "users",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_lesson_complete_lessonId_lessons_id_fk": {
"name": "user_lesson_complete_lessonId_lessons_id_fk",
"tableFrom": "user_lesson_complete",
"tableTo": "lessons",
"columnsFrom": [
"lessonId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_lesson_complete_userId_lessonId_pk": {
"name": "user_lesson_complete_userId_lessonId_pk",
"columns": [
"userId",
"lessonId"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.course_section_status": {
"name": "course_section_status",
"schema": "public",
"values": [
"public",
"private"
]
},
"public.lesson_status": {
"name": "lesson_status",
"schema": "public",
"values": [
"public",
"private",
"preview"
]
},
"public.product_status": {
"name": "product_status",
"schema": "public",
"values": [
"public",
"private"
]
},
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": [
"user",
"admin"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
================================================
FILE: src/drizzle/migrations/meta/_journal.json
================================================
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1736952636321,
"tag": "0000_orange_wind_dancer",
"breakpoints": true
}
]
}
================================================
FILE: src/drizzle/schema/course.ts
================================================
import { relations } from "drizzle-orm"
import { pgTable, text } from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { CourseProductTable } from "./courseProduct"
import { UserCourseAccessTable } from "./userCourseAccess"
import { CourseSectionTable } from "./courseSection"
export const CourseTable = pgTable("courses", {
id,
name: text().notNull(),
description: text().notNull(),
createdAt,
updatedAt,
})
export const CourseRelationships = relations(CourseTable, ({ many }) => ({
courseProducts: many(CourseProductTable),
userCourseAccesses: many(UserCourseAccessTable),
courseSections: many(CourseSectionTable),
}))
================================================
FILE: src/drizzle/schema/courseProduct.ts
================================================
import { pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"
import { CourseTable } from "./course"
import { ProductTable } from "./product"
import { createdAt, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"
export const CourseProductTable = pgTable(
"course_products",
{
courseId: uuid()
.notNull()
.references(() => CourseTable.id, { onDelete: "restrict" }),
productId: uuid()
.notNull()
.references(() => ProductTable.id, { onDelete: "cascade" }),
createdAt,
updatedAt,
},
t => [primaryKey({ columns: [t.courseId, t.productId] })]
)
export const CourseProductRelationships = relations(
CourseProductTable,
({ one }) => ({
course: one(CourseTable, {
fields: [CourseProductTable.courseId],
references: [CourseTable.id],
}),
product: one(ProductTable, {
fields: [CourseProductTable.productId],
references: [ProductTable.id],
}),
})
)
================================================
FILE: src/drizzle/schema/courseSection.ts
================================================
import { integer, pgEnum, pgTable, text, uuid } from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { CourseTable } from "./course"
import { relations } from "drizzle-orm"
import { LessonTable } from "./lesson"
export const courseSectionStatuses = ["public", "private"] as const
export type CourseSectionStatus = (typeof courseSectionStatuses)[number]
export const courseSectionStatusEnum = pgEnum(
"course_section_status",
courseSectionStatuses
)
export const CourseSectionTable = pgTable("course_sections", {
id,
name: text().notNull(),
status: courseSectionStatusEnum().notNull().default("private"),
order: integer().notNull(),
courseId: uuid()
.notNull()
.references(() => CourseTable.id, { onDelete: "cascade" }),
createdAt,
updatedAt,
})
export const CourseSectionRelationships = relations(
CourseSectionTable,
({ many, one }) => ({
course: one(CourseTable, {
fields: [CourseSectionTable.courseId],
references: [CourseTable.id],
}),
lessons: many(LessonTable),
})
)
================================================
FILE: src/drizzle/schema/lesson.ts
================================================
import { pgTable, text, uuid, integer, pgEnum } from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"
import { CourseSectionTable } from "./courseSection"
import { UserLessonCompleteTable } from "./userLessonComplete"
export const lessonStatuses = ["public", "private", "preview"] as const
export type LessonStatus = (typeof lessonStatuses)[number]
export const lessonStatusEnum = pgEnum("lesson_status", lessonStatuses)
export const LessonTable = pgTable("lessons", {
id,
name: text().notNull(),
description: text(),
youtubeVideoId: text().notNull(),
order: integer().notNull(),
status: lessonStatusEnum().notNull().default("private"),
sectionId: uuid()
.notNull()
.references(() => CourseSectionTable.id, { onDelete: "cascade" }),
createdAt,
updatedAt,
})
export const LessonRelationships = relations(LessonTable, ({ one, many }) => ({
section: one(CourseSectionTable, {
fields: [LessonTable.sectionId],
references: [CourseSectionTable.id],
}),
userLessonsComplete: many(UserLessonCompleteTable),
}))
================================================
FILE: src/drizzle/schema/product.ts
================================================
import { relations } from "drizzle-orm"
import { pgTable, text, integer, pgEnum } from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { CourseProductTable } from "./courseProduct"
export const productStatuses = ["public", "private"] as const
export type ProductStatus = (typeof productStatuses)[number]
export const productStatusEnum = pgEnum("product_status", productStatuses)
export const ProductTable = pgTable("products", {
id,
name: text().notNull(),
description: text().notNull(),
imageUrl: text().notNull(),
priceInDollars: integer().notNull(),
status: productStatusEnum().notNull().default("private"),
createdAt,
updatedAt,
})
export const ProductRelationships = relations(ProductTable, ({ many }) => ({
courseProducts: many(CourseProductTable),
}))
================================================
FILE: src/drizzle/schema/purchase.ts
================================================
import {
pgTable,
integer,
jsonb,
uuid,
text,
timestamp,
} from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"
import { UserTable } from "./user"
import { ProductTable } from "./product"
export const PurchaseTable = pgTable("purchases", {
id,
pricePaidInCents: integer().notNull(),
productDetails: jsonb()
.notNull()
.$type<{ name: string; description: string; imageUrl: string }>(),
userId: uuid()
.notNull()
.references(() => UserTable.id, { onDelete: "restrict" }),
productId: uuid()
.notNull()
.references(() => ProductTable.id, { onDelete: "restrict" }),
stripeSessionId: text().notNull().unique(),
refundedAt: timestamp({ withTimezone: true }),
createdAt,
updatedAt,
})
export const PurchaseRelationships = relations(PurchaseTable, ({ one }) => ({
user: one(UserTable, {
fields: [PurchaseTable.userId],
references: [UserTable.id],
}),
product: one(ProductTable, {
fields: [PurchaseTable.productId],
references: [ProductTable.id],
}),
}))
================================================
FILE: src/drizzle/schema/user.ts
================================================
import { pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"
import { UserCourseAccessTable } from "./userCourseAccess"
export const userRoles = ["user", "admin"] as const
export type UserRole = (typeof userRoles)[number]
export const userRoleEnum = pgEnum("user_role", userRoles)
export const UserTable = pgTable("users", {
id,
clerkUserId: text().notNull().unique(),
email: text().notNull(),
name: text().notNull(),
role: userRoleEnum().notNull().default("user"),
imageUrl: text(),
deletedAt: timestamp({ withTimezone: true }),
createdAt,
updatedAt,
})
export const UserRelationships = relations(UserTable, ({ many }) => ({
userCourseAccesses: many(UserCourseAccessTable),
}))
================================================
FILE: src/drizzle/schema/userCourseAccess.ts
================================================
import { pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"
import { createdAt, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"
import { UserTable } from "./user"
import { CourseTable } from "./course"
export const UserCourseAccessTable = pgTable(
"user_course_access",
{
userId: uuid()
.notNull()
.references(() => UserTable.id, { onDelete: "cascade" }),
courseId: uuid()
.notNull()
.references(() => CourseTable.id, { onDelete: "cascade" }),
createdAt,
updatedAt,
},
t => [primaryKey({ columns: [t.userId, t.courseId] })]
)
export const UserCourseAccessRelationships = relations(
UserCourseAccessTable,
({ one }) => ({
user: one(UserTable, {
fields: [UserCourseAccessTable.userId],
references: [UserTable.id],
}),
course: one(CourseTable, {
fields: [UserCourseAccessTable.courseId],
references: [CourseTable.id],
}),
})
)
================================================
FILE: src/drizzle/schema/userLessonComplete.ts
================================================
import { pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"
import { createdAt, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"
import { UserTable } from "./user"
import { LessonTable } from "./lesson"
export const UserLessonCompleteTable = pgTable(
"user_lesson_complete",
{
userId: uuid()
.notNull()
.references(() => UserTable.id, { onDelete: "cascade" }),
lessonId: uuid()
.notNull()
.references(() => LessonTable.id, { onDelete: "cascade" }),
createdAt,
updatedAt,
},
t => [primaryKey({ columns: [t.userId, t.lessonId] })]
)
export const UserLessonCompleteRelationships = relations(
UserLessonCompleteTable,
({ one }) => ({
user: one(UserTable, {
fields: [UserLessonCompleteTable.userId],
references: [UserTable.id],
}),
lesson: one(LessonTable, {
fields: [UserLessonCompleteTable.lessonId],
references: [LessonTable.id],
}),
})
)
================================================
FILE: src/drizzle/schema.ts
================================================
export * from "./schema/course"
export * from "./schema/courseProduct"
export * from "./schema/courseSection"
export * from "./schema/lesson"
export * from "./schema/product"
export * from "./schema/purchase"
export * from "./schema/user"
export * from "./schema/userCourseAccess"
export * from "./schema/userLessonComplete"
================================================
FILE: src/drizzle/schemaHelpers.ts
================================================
import { timestamp, uuid } from "drizzle-orm/pg-core"
export const id = uuid().primaryKey().defaultRandom()
export const createdAt = timestamp({ withTimezone: true })
.notNull()
.defaultNow()
export const updatedAt = timestamp({ withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
================================================
FILE: src/features/courseSections/actions/sections.ts
================================================
"use server"
import { z } from "zod"
import { getCurrentUser } from "@/services/clerk"
import { sectionSchema } from "../schemas/sections"
import {
canCreateCourseSections,
canDeleteCourseSections,
canUpdateCourseSections,
} from "../permissions/sections"
import {
getNextCourseSectionOrder,
insertSection,
updateSection as updateSectionDb,
deleteSection as deleteSectionDb,
updateSectionOrders as updateSectionOrdersDb,
} from "../db/sections"
export async function createSection(
courseId: string,
unsafeData: z.infer
) {
const { success, data } = sectionSchema.safeParse(unsafeData)
if (!success || !canCreateCourseSections(await getCurrentUser())) {
return { error: true, message: "There was an error creating your section" }
}
const order = await getNextCourseSectionOrder(courseId)
await insertSection({ ...data, courseId, order })
return { error: false, message: "Successfully created your section" }
}
export async function updateSection(
id: string,
unsafeData: z.infer
) {
const { success, data } = sectionSchema.safeParse(unsafeData)
if (!success || !canUpdateCourseSections(await getCurrentUser())) {
return { error: true, message: "There was an error updating your section" }
}
await updateSectionDb(id, data)
return { error: false, message: "Successfully updated your section" }
}
export async function deleteSection(id: string) {
if (!canDeleteCourseSections(await getCurrentUser())) {
return { error: true, message: "Error deleting your section" }
}
await deleteSectionDb(id)
return { error: false, message: "Successfully deleted your section" }
}
export async function updateSectionOrders(sectionIds: string[]) {
if (
sectionIds.length === 0 ||
!canUpdateCourseSections(await getCurrentUser())
) {
return { error: true, message: "Error reordering your sections" }
}
await updateSectionOrdersDb(sectionIds)
return { error: false, message: "Successfully reordered your sections" }
}
================================================
FILE: src/features/courseSections/components/SectionForm.tsx
================================================
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { sectionSchema } from "../schemas/sections"
import { z } from "zod"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { RequiredLabelIcon } from "@/components/RequiredLabelIcon"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { actionToast } from "@/hooks/use-toast"
import { CourseSectionStatus, courseSectionStatuses } from "@/drizzle/schema"
import {
Select,
SelectItem,
SelectTrigger,
SelectValue,
SelectContent,
} from "@/components/ui/select"
import { createSection, updateSection } from "../actions/sections"
export function SectionForm({
section,
courseId,
onSuccess,
}: {
section?: {
id: string
name: string
status: CourseSectionStatus
}
courseId: string
onSuccess?: () => void
}) {
const form = useForm>({
resolver: zodResolver(sectionSchema),
defaultValues: section ?? {
name: "",
status: "public",
},
})
async function onSubmit(values: z.infer) {
const action =
section == null
? createSection.bind(null, courseId)
: updateSection.bind(null, section.id)
const data = await action(values)
actionToast({ actionData: data })
if (!data.error) onSuccess?.()
}
return (
)
}
================================================
FILE: src/features/courseSections/components/SectionFormDialog.tsx
================================================
"use client"
import {
Dialog,
DialogHeader,
DialogTitle,
DialogContent,
} from "@/components/ui/dialog"
import { CourseSectionStatus } from "@/drizzle/schema"
import { ReactNode, useState } from "react"
import { SectionForm } from "./SectionForm"
export function SectionFormDialog({
courseId,
section,
children,
}: {
courseId: string
children: ReactNode
section?: { id: string; name: string; status: CourseSectionStatus }
}) {
const [isOpen, setIsOpen] = useState(false)
return (
)
}
================================================
FILE: src/features/courseSections/components/SortableSectionList.tsx
================================================
"use client"
import { SortableItem, SortableList } from "@/components/SortableList"
import { CourseSectionStatus } from "@/drizzle/schema"
import { cn } from "@/lib/utils"
import { EyeClosed, Trash2Icon } from "lucide-react"
import { SectionFormDialog } from "./SectionFormDialog"
import { Button } from "@/components/ui/button"
import { ActionButton } from "@/components/ActionButton"
import { deleteSection, updateSectionOrders } from "../actions/sections"
import { DialogTrigger } from "@/components/ui/dialog"
export function SortableSectionList({
courseId,
sections,
}: {
courseId: string
sections: {
id: string
name: string
status: CourseSectionStatus
}[]
}) {
return (
{items =>
items.map(section => (
{section.status === "private" && }
{section.name}
Delete
))
}
)
}
================================================
FILE: src/features/courseSections/db/cache.ts
================================================
import { getCourseTag, getGlobalTag, getIdTag } from "@/lib/dataCache"
import { revalidateTag } from "next/cache"
export function getCourseSectionGlobalTag() {
return getGlobalTag("courseSections")
}
export function getCourseSectionIdTag(id: string) {
return getIdTag("courseSections", id)
}
export function getCourseSectionCourseTag(courseId: string) {
return getCourseTag("courseSections", courseId)
}
export function revalidateCourseSectionCache({
id,
courseId,
}: {
id: string
courseId: string
}) {
revalidateTag(getCourseSectionGlobalTag())
revalidateTag(getCourseSectionIdTag(id))
revalidateTag(getCourseSectionCourseTag(courseId))
}
================================================
FILE: src/features/courseSections/db/sections.ts
================================================
import { CourseSectionTable } from "@/drizzle/schema"
import { revalidateCourseSectionCache } from "./cache"
import { db } from "@/drizzle/db"
import { eq } from "drizzle-orm"
export async function getNextCourseSectionOrder(courseId: string) {
const section = await db.query.CourseSectionTable.findFirst({
columns: { order: true },
where: ({ courseId: courseIdCol }, { eq }) => eq(courseIdCol, courseId),
orderBy: ({ order }, { desc }) => desc(order),
})
return section ? section.order + 1 : 0
}
export async function insertSection(
data: typeof CourseSectionTable.$inferInsert
) {
const [newSection] = await db
.insert(CourseSectionTable)
.values(data)
.returning()
if (newSection == null) throw new Error("Failed to create section")
revalidateCourseSectionCache({
courseId: newSection.courseId,
id: newSection.id,
})
return newSection
}
export async function updateSection(
id: string,
data: Partial
) {
const [updatedSection] = await db
.update(CourseSectionTable)
.set(data)
.where(eq(CourseSectionTable.id, id))
.returning()
if (updatedSection == null) throw new Error("Failed to update section")
revalidateCourseSectionCache({
courseId: updatedSection.courseId,
id: updatedSection.id,
})
return updatedSection
}
export async function deleteSection(id: string) {
const [deletedSection] = await db
.delete(CourseSectionTable)
.where(eq(CourseSectionTable.id, id))
.returning()
if (deletedSection == null) throw new Error("Failed to delete section")
revalidateCourseSectionCache({
courseId: deletedSection.courseId,
id: deletedSection.id,
})
return deletedSection
}
export async function updateSectionOrders(sectionIds: string[]) {
const sections = await Promise.all(
sectionIds.map((id, index) =>
db
.update(CourseSectionTable)
.set({ order: index })
.where(eq(CourseSectionTable.id, id))
.returning({
courseId: CourseSectionTable.courseId,
id: CourseSectionTable.id,
})
)
)
sections.flat().forEach(({ id, courseId }) => {
revalidateCourseSectionCache({
courseId,
id,
})
})
}
================================================
FILE: src/features/courseSections/permissions/sections.ts
================================================
import { CourseSectionTable, UserRole } from "@/drizzle/schema"
import { eq } from "drizzle-orm"
export function canCreateCourseSections({
role,
}: {
role: UserRole | undefined
}) {
return role === "admin"
}
export function canUpdateCourseSections({
role,
}: {
role: UserRole | undefined
}) {
return role === "admin"
}
export function canDeleteCourseSections({
role,
}: {
role: UserRole | undefined
}) {
return role === "admin"
}
export const wherePublicCourseSections = eq(CourseSectionTable.status, "public")
================================================
FILE: src/features/courseSections/schemas/sections.ts
================================================
import { courseSectionStatuses } from "@/drizzle/schema"
import { z } from "zod"
export const sectionSchema = z.object({
name: z.string().min(1, "Required"),
status: z.enum(courseSectionStatuses),
})
================================================
FILE: src/features/courses/actions/courses.ts
================================================
"use server"
import { z } from "zod"
import { courseSchema } from "../schemas/courses"
import { redirect } from "next/navigation"
import { getCurrentUser } from "@/services/clerk"
import {
canCreateCourses,
canDeleteCourses,
canUpdateCourses,
} from "../permissions/courses"
import {
insertCourse,
deleteCourse as deleteCourseDB,
updateCourse as updateCourseDb,
} from "../db/courses"
export async function createCourse(unsafeData: z.infer) {
const { success, data } = courseSchema.safeParse(unsafeData)
if (!success || !canCreateCourses(await getCurrentUser())) {
return { error: true, message: "There was an error creating your course" }
}
const course = await insertCourse(data)
redirect(`/admin/courses/${course.id}/edit`)
}
export async function updateCourse(
id: string,
unsafeData: z.infer
) {
const { success, data } = courseSchema.safeParse(unsafeData)
if (!success || !canUpdateCourses(await getCurrentUser())) {
return { error: true, message: "There was an error updating your course" }
}
await updateCourseDb(id, data)
return { error: false, message: "Successfully updated your course" }
}
export async function deleteCourse(id: string) {
if (!canDeleteCourses(await getCurrentUser())) {
return { error: true, message: "Error deleting your course" }
}
await deleteCourseDB(id)
return { error: false, message: "Successfully deleted your course" }
}
================================================
FILE: src/features/courses/components/CourseForm.tsx
================================================
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { courseSchema } from "../schemas/courses"
import { z } from "zod"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { RequiredLabelIcon } from "@/components/RequiredLabelIcon"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
import { createCourse, updateCourse } from "../actions/courses"
import { actionToast } from "@/hooks/use-toast"
export function CourseForm({
course,
}: {
course?: {
id: string
name: string
description: string
}
}) {
const form = useForm>({
resolver: zodResolver(courseSchema),
defaultValues: course ?? {
name: "",
description: "",
},
})
async function onSubmit(values: z.infer) {
const action =
course == null ? createCourse : updateCourse.bind(null, course.id)
const data = await action(values)
actionToast({ actionData: data })
}
return (
)
}
================================================
FILE: src/features/courses/components/CourseTable.tsx
================================================
import { ActionButton } from "@/components/ActionButton"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { formatPlural } from "@/lib/formatters"
import { Trash2Icon } from "lucide-react"
import Link from "next/link"
import { deleteCourse } from "../actions/courses"
export function CourseTable({
courses,
}: {
courses: {
id: string
name: string
sectionsCount: number
lessonsCount: number
studentsCount: number
}[]
}) {
return (
{formatPlural(courses.length, {
singular: "course",
plural: "courses",
})}
Students
Actions
{courses.map(course => (
{course.name}
{formatPlural(course.sectionsCount, {
singular: "section",
plural: "sections",
})}{" "}
•{" "}
{formatPlural(course.lessonsCount, {
singular: "lesson",
plural: "lessons",
})}
{course.studentsCount}
))}
)
}
================================================
FILE: src/features/courses/db/cache/courses.ts
================================================
import { getGlobalTag, getIdTag } from "@/lib/dataCache"
import { revalidateTag } from "next/cache"
export function getCourseGlobalTag() {
return getGlobalTag("courses")
}
export function getCourseIdTag(id: string) {
return getIdTag("courses", id)
}
export function revalidateCourseCache(id: string) {
revalidateTag(getCourseGlobalTag())
revalidateTag(getCourseIdTag(id))
}
================================================
FILE: src/features/courses/db/cache/userCourseAccess.ts
================================================
import { getGlobalTag, getIdTag, getUserTag } from "@/lib/dataCache"
import { revalidateTag } from "next/cache"
export function getUserCourseAccessGlobalTag() {
return getGlobalTag("userCourseAccess")
}
export function getUserCourseAccessIdTag({
courseId,
userId,
}: {
courseId: string
userId: string
}) {
return getIdTag("userCourseAccess", `course:${courseId}-user:${userId}`)
}
export function getUserCourseAccessUserTag(userId: string) {
return getUserTag("userCourseAccess", userId)
}
export function revalidateUserCourseAccessCache({
courseId,
userId,
}: {
courseId: string
userId: string
}) {
revalidateTag(getUserCourseAccessGlobalTag())
revalidateTag(getUserCourseAccessIdTag({ courseId, userId }))
revalidateTag(getUserCourseAccessUserTag(userId))
}
================================================
FILE: src/features/courses/db/courses.ts
================================================
import { db } from "@/drizzle/db"
import { CourseTable } from "@/drizzle/schema"
import { revalidateCourseCache } from "./cache/courses"
import { eq } from "drizzle-orm"
export async function insertCourse(data: typeof CourseTable.$inferInsert) {
const [newCourse] = await db.insert(CourseTable).values(data).returning()
if (newCourse == null) throw new Error("Failed to create course")
revalidateCourseCache(newCourse.id)
return newCourse
}
export async function updateCourse(
id: string,
data: typeof CourseTable.$inferInsert
) {
const [updatedCourse] = await db
.update(CourseTable)
.set(data)
.where(eq(CourseTable.id, id))
.returning()
if (updatedCourse == null) throw new Error("Failed to update course")
revalidateCourseCache(updatedCourse.id)
return updatedCourse
}
export async function deleteCourse(id: string) {
const [deletedCourse] = await db
.delete(CourseTable)
.where(eq(CourseTable.id, id))
.returning()
if (deletedCourse == null) throw new Error("Failed to delete course")
revalidateCourseCache(deletedCourse.id)
return deletedCourse
}
================================================
FILE: src/features/courses/db/userCourseAcccess.ts
================================================
import { db } from "@/drizzle/db"
import {
ProductTable,
PurchaseTable,
UserCourseAccessTable,
} from "@/drizzle/schema"
import { revalidateUserCourseAccessCache } from "./cache/userCourseAccess"
import { and, eq, inArray, isNull } from "drizzle-orm"
export async function addUserCourseAccess(
{
userId,
courseIds,
}: {
userId: string
courseIds: string[]
},
trx: Omit = db
) {
const accesses = await trx
.insert(UserCourseAccessTable)
.values(courseIds.map(courseId => ({ userId, courseId })))
.onConflictDoNothing()
.returning()
accesses.forEach(revalidateUserCourseAccessCache)
return accesses
}
export async function revokeUserCourseAccess(
{
userId,
productId,
}: {
userId: string
productId: string
},
trx: Omit = db
) {
const validPurchases = await trx.query.PurchaseTable.findMany({
where: and(
eq(PurchaseTable.userId, userId),
isNull(PurchaseTable.refundedAt)
),
with: {
product: {
with: { courseProducts: { columns: { courseId: true } } },
},
},
})
const refundPurchase = await trx.query.ProductTable.findFirst({
where: eq(ProductTable.id, productId),
with: { courseProducts: { columns: { courseId: true } } },
})
if (refundPurchase == null) return
const validCourseIds = validPurchases.flatMap(p =>
p.product.courseProducts.map(cp => cp.courseId)
)
const removeCourseIds = refundPurchase.courseProducts
.flatMap(cp => cp.courseId)
.filter(courseId => !validCourseIds.includes(courseId))
const revokedAccesses = await trx
.delete(UserCourseAccessTable)
.where(
and(
eq(UserCourseAccessTable.userId, userId),
inArray(UserCourseAccessTable.courseId, removeCourseIds)
)
)
.returning()
revokedAccesses.forEach(revalidateUserCourseAccessCache)
return revokedAccesses
}
================================================
FILE: src/features/courses/permissions/courses.ts
================================================
import { UserRole } from "@/drizzle/schema"
export function canCreateCourses({ role }: { role: UserRole | undefined }) {
return role === "admin"
}
export function canUpdateCourses({ role }: { role: UserRole | undefined }) {
return role === "admin"
}
export function canDeleteCourses({ role }: { role: UserRole | undefined }) {
return role === "admin"
}
================================================
FILE: src/features/courses/schemas/courses.ts
================================================
import { z } from "zod"
export const courseSchema = z.object({
name: z.string().min(1, "Required"),
description: z.string().min(1, "Required"),
})
================================================
FILE: src/features/lessons/actions/lessons.ts
================================================
"use server"
import { z } from "zod"
import { lessonSchema } from "../schemas/lessons"
import { getCurrentUser } from "@/services/clerk"
import {
canCreateLessons,
canDeleteLessons,
canUpdateLessons,
} from "../permissions/lessons"
import {
getNextCourseLessonOrder,
insertLesson,
updateLesson as updateLessonDb,
deleteLesson as deleteLessonDb,
updateLessonOrders as updateLessonOrdersDb,
} from "../db/lessons"
export async function createLesson(unsafeData: z.infer) {
const { success, data } = lessonSchema.safeParse(unsafeData)
if (!success || !canCreateLessons(await getCurrentUser())) {
return { error: true, message: "There was an error creating your lesson" }
}
const order = await getNextCourseLessonOrder(data.sectionId)
await insertLesson({ ...data, order })
return { error: false, message: "Successfully created your lesson" }
}
export async function updateLesson(
id: string,
unsafeData: z.infer
) {
const { success, data } = lessonSchema.safeParse(unsafeData)
if (!success || !canUpdateLessons(await getCurrentUser())) {
return { error: true, message: "There was an error updating your lesson" }
}
await updateLessonDb(id, data)
return { error: false, message: "Successfully updated your lesson" }
}
export async function deleteLesson(id: string) {
if (!canDeleteLessons(await getCurrentUser())) {
return { error: true, message: "Error deleting your lesson" }
}
await deleteLessonDb(id)
return { error: false, message: "Successfully deleted your lesson" }
}
export async function updateLessonOrders(lessonIds: string[]) {
if (lessonIds.length === 0 || !canUpdateLessons(await getCurrentUser())) {
return { error: true, message: "Error reordering your lessons" }
}
await updateLessonOrdersDb(lessonIds)
return { error: false, message: "Successfully reordered your lessons" }
}
================================================
FILE: src/features/lessons/actions/userLessonComplete.ts
================================================
"use server"
import { getCurrentUser } from "@/services/clerk"
import { canUpdateUserLessonCompleteStatus } from "../permissions/userLessonComplete"
import { updateLessonCompleteStatus as updateLessonCompleteStatusDb } from "../db/userLessonComplete"
export async function updateLessonCompleteStatus(
lessonId: string,
complete: boolean
) {
const { userId } = await getCurrentUser()
const hasPermission = await canUpdateUserLessonCompleteStatus(
{ userId },
lessonId
)
if (userId == null || !hasPermission) {
return { error: true, message: "Error updating lesson completion status" }
}
await updateLessonCompleteStatusDb({ lessonId, userId, complete })
return {
error: false,
message: "Successfully updated lesson completion status",
}
}
================================================
FILE: src/features/lessons/components/LessonForm.tsx
================================================
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { RequiredLabelIcon } from "@/components/RequiredLabelIcon"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { actionToast } from "@/hooks/use-toast"
import { LessonStatus, lessonStatuses } from "@/drizzle/schema"
import {
Select,
SelectItem,
SelectTrigger,
SelectValue,
SelectContent,
} from "@/components/ui/select"
import { lessonSchema } from "../schemas/lessons"
import { Textarea } from "@/components/ui/textarea"
import { createLesson, updateLesson } from "../actions/lessons"
import { YouTubeVideoPlayer } from "./YouTubeVideoPlayer"
export function LessonForm({
sections,
defaultSectionId,
onSuccess,
lesson,
}: {
sections: {
id: string
name: string
}[]
onSuccess?: () => void
defaultSectionId?: string
lesson?: {
id: string
name: string
status: LessonStatus
youtubeVideoId: string
description: string | null
sectionId: string
}
}) {
const form = useForm>({
resolver: zodResolver(lessonSchema),
defaultValues: {
name: lesson?.name ?? "",
status: lesson?.status ?? "public",
youtubeVideoId: lesson?.youtubeVideoId ?? "",
description: lesson?.description ?? "",
sectionId: lesson?.sectionId ?? defaultSectionId ?? sections[0]?.id ?? "",
},
})
async function onSubmit(values: z.infer) {
const action =
lesson == null ? createLesson : updateLesson.bind(null, lesson.id)
const data = await action(values)
actionToast({ actionData: data })
if (!data.error) onSuccess?.()
}
const videoId = form.watch("youtubeVideoId")
console.log(videoId)
return (
)
}
================================================
FILE: src/features/lessons/components/LessonFormDialog.tsx
================================================
"use client"
import {
Dialog,
DialogHeader,
DialogTitle,
DialogContent,
} from "@/components/ui/dialog"
import { LessonStatus } from "@/drizzle/schema"
import { ReactNode, useState } from "react"
import { LessonForm } from "./LessonForm"
export function LessonFormDialog({
sections,
defaultSectionId,
lesson,
children,
}: {
children: ReactNode
sections: { id: string; name: string }[]
defaultSectionId?: string
lesson?: {
id: string
name: string
status: LessonStatus
youtubeVideoId: string
description: string | null
sectionId: string
}
}) {
const [isOpen, setIsOpen] = useState(false)
return (
)
}
================================================
FILE: src/features/lessons/components/SortableLessonList.tsx
================================================
"use client"
import { SortableItem, SortableList } from "@/components/SortableList"
import { LessonStatus } from "@/drizzle/schema"
import { cn } from "@/lib/utils"
import { EyeClosed, Trash2Icon, VideoIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ActionButton } from "@/components/ActionButton"
import { DialogTrigger } from "@/components/ui/dialog"
import { LessonFormDialog } from "./LessonFormDialog"
import { deleteLesson, updateLessonOrders } from "../actions/lessons"
export function SortableLessonList({
sections,
lessons,
}: {
sections: {
id: string
name: string
}[]
lessons: {
id: string
name: string
status: LessonStatus
youtubeVideoId: string
description: string | null
sectionId: string
}[]
}) {
return (
{items =>
items.map(lesson => (
{lesson.status === "private" && }
{lesson.status === "preview" && }
{lesson.name}
Delete
))
}
)
}
================================================
FILE: src/features/lessons/components/YouTubeVideoPlayer.tsx
================================================
"use client"
import YouTube from "react-youtube"
export function YouTubeVideoPlayer({
videoId,
onFinishedVideo,
}: {
videoId: string
onFinishedVideo?: () => void
}) {
return (
)
}
================================================
FILE: src/features/lessons/db/cache/lessons.ts
================================================
import { getCourseTag, getGlobalTag, getIdTag } from "@/lib/dataCache"
import { revalidateTag } from "next/cache"
export function getLessonGlobalTag() {
return getGlobalTag("lessons")
}
export function getLessonIdTag(id: string) {
return getIdTag("lessons", id)
}
export function getLessonCourseTag(courseId: string) {
return getCourseTag("lessons", courseId)
}
export function revalidateLessonCache({
id,
courseId,
}: {
id: string
courseId: string
}) {
revalidateTag(getLessonGlobalTag())
revalidateTag(getLessonIdTag(id))
revalidateTag(getLessonCourseTag(courseId))
}
================================================
FILE: src/features/lessons/db/cache/userLessonComplete.ts
================================================
import { getGlobalTag, getIdTag, getUserTag } from "@/lib/dataCache"
import { revalidateTag } from "next/cache"
export function getUserLessonCompleteGlobalTag() {
return getGlobalTag("userLessonComplete")
}
export function getUserLessonCompleteIdTag({
lessonId,
userId,
}: {
lessonId: string
userId: string
}) {
return getIdTag("userLessonComplete", `lesson:${lessonId}-user:${userId}`)
}
export function getUserLessonCompleteUserTag(userId: string) {
return getUserTag("userLessonComplete", userId)
}
export function revalidateUserLessonCompleteCache({
lessonId,
userId,
}: {
lessonId: string
userId: string
}) {
revalidateTag(getUserLessonCompleteGlobalTag())
revalidateTag(getUserLessonCompleteIdTag({ lessonId, userId }))
revalidateTag(getUserLessonCompleteUserTag(userId))
}
================================================
FILE: src/features/lessons/db/lessons.ts
================================================
import { db } from "@/drizzle/db"
import { CourseSectionTable, LessonTable } from "@/drizzle/schema"
import { eq } from "drizzle-orm"
import { revalidateLessonCache } from "./cache/lessons"
export async function getNextCourseLessonOrder(sectionId: string) {
const lesson = await db.query.LessonTable.findFirst({
columns: { order: true },
where: ({ sectionId: sectionIdCol }, { eq }) => eq(sectionIdCol, sectionId),
orderBy: ({ order }, { desc }) => desc(order),
})
return lesson ? lesson.order + 1 : 0
}
export async function insertLesson(data: typeof LessonTable.$inferInsert) {
const [newLesson, courseId] = await db.transaction(async trx => {
const [[newLesson], section] = await Promise.all([
trx.insert(LessonTable).values(data).returning(),
trx.query.CourseSectionTable.findFirst({
columns: { courseId: true },
where: eq(CourseSectionTable.id, data.sectionId),
}),
])
if (section == null) return trx.rollback()
return [newLesson, section.courseId]
})
if (newLesson == null) throw new Error("Failed to create lesson")
revalidateLessonCache({ courseId, id: newLesson.id })
return newLesson
}
export async function updateLesson(
id: string,
data: Partial
) {
const [updatedLesson, courseId] = await db.transaction(async trx => {
const currentLesson = await trx.query.LessonTable.findFirst({
where: eq(LessonTable.id, id),
columns: { sectionId: true },
})
if (
data.sectionId != null &&
currentLesson?.sectionId !== data.sectionId &&
data.order == null
) {
data.order = await getNextCourseLessonOrder(data.sectionId)
}
const [updatedLesson] = await trx
.update(LessonTable)
.set(data)
.where(eq(LessonTable.id, id))
.returning()
if (updatedLesson == null) {
trx.rollback()
throw new Error("Failed to update lesson")
}
const section = await trx.query.CourseSectionTable.findFirst({
columns: { courseId: true },
where: eq(CourseSectionTable.id, updatedLesson.sectionId),
})
if (section == null) return trx.rollback()
return [updatedLesson, section.courseId]
})
revalidateLessonCache({ courseId, id: updatedLesson.id })
return updatedLesson
}
export async function deleteLesson(id: string) {
const [deletedLesson, courseId] = await db.transaction(async trx => {
const [deletedLesson] = await trx
.delete(LessonTable)
.where(eq(LessonTable.id, id))
.returning()
if (deletedLesson == null) {
trx.rollback()
throw new Error("Failed to delete lesson")
}
const section = await trx.query.CourseSectionTable.findFirst({
columns: { courseId: true },
where: ({ id }, { eq }) => eq(id, deletedLesson.sectionId),
})
if (section == null) return trx.rollback()
return [deletedLesson, section.courseId]
})
revalidateLessonCache({
id: deletedLesson.id,
courseId,
})
return deletedLesson
}
export async function updateLessonOrders(lessonIds: string[]) {
const [lessons, courseId] = await db.transaction(async trx => {
const lessons = await Promise.all(
lessonIds.map((id, index) =>
db
.update(LessonTable)
.set({ order: index })
.where(eq(LessonTable.id, id))
.returning({
sectionId: LessonTable.sectionId,
id: LessonTable.id,
})
)
)
const sectionId = lessons[0]?.[0]?.sectionId
if (sectionId == null) return trx.rollback()
const section = await trx.query.CourseSectionTable.findFirst({
columns: { courseId: true },
where: ({ id }, { eq }) => eq(id, sectionId),
})
if (section == null) return trx.rollback()
return [lessons, section.courseId]
})
lessons.flat().forEach(({ id }) => {
revalidateLessonCache({
courseId,
id,
})
})
}
================================================
FILE: src/features/lessons/db/userLessonComplete.ts
================================================
import { db } from "@/drizzle/db"
import { UserLessonCompleteTable } from "@/drizzle/schema"
import { and, eq } from "drizzle-orm"
import { revalidateUserLessonCompleteCache } from "./cache/userLessonComplete"
export async function updateLessonCompleteStatus({
lessonId,
userId,
complete,
}: {
lessonId: string
userId: string
complete: boolean
}) {
let completion: { lessonId: string; userId: string } | undefined
if (complete) {
const [c] = await db
.insert(UserLessonCompleteTable)
.values({
lessonId,
userId,
})
.onConflictDoNothing()
.returning()
completion = c
} else {
const [c] = await db
.delete(UserLessonCompleteTable)
.where(
and(
eq(UserLessonCompleteTable.lessonId, lessonId),
eq(UserLessonCompleteTable.userId, userId)
)
)
.returning()
completion = c
}
if (completion == null) return
revalidateUserLessonCompleteCache({
lessonId: completion.lessonId,
userId: completion.userId,
})
return completion
}
================================================
FILE: src/features/lessons/permissions/lessons.ts
================================================
import { db } from "@/drizzle/db"
import {
CourseSectionTable,
CourseTable,
LessonStatus,
LessonTable,
UserCourseAccessTable,
UserRole,
} from "@/drizzle/schema"
import { getUserCourseAccessUserTag } from "@/features/courses/db/cache/userCourseAccess"
import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections"
import { and, eq, or } from "drizzle-orm"
import { getLessonIdTag } from "../db/cache/lessons"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
export function canCreateLessons({ role }: { role: UserRole | undefined }) {
return role === "admin"
}
export function canUpdateLessons({ role }: { role: UserRole | undefined }) {
return role === "admin"
}
export function canDeleteLessons({ role }: { role: UserRole | undefined }) {
return role === "admin"
}
export async function canViewLesson(
{
role,
userId,
}: {
userId: string | undefined
role: UserRole | undefined
},
lesson: { id: string; status: LessonStatus }
) {
"use cache"
if (role === "admin" || lesson.status === "preview") return true
if (userId == null || lesson.status === "private") return false
cacheTag(getUserCourseAccessUserTag(userId), getLessonIdTag(lesson.id))
const [data] = await db
.select({ courseId: CourseTable.id })
.from(UserCourseAccessTable)
.leftJoin(CourseTable, eq(CourseTable.id, UserCourseAccessTable.courseId))
.leftJoin(
CourseSectionTable,
and(
eq(CourseSectionTable.courseId, CourseTable.id),
wherePublicCourseSections
)
)
.leftJoin(
LessonTable,
and(eq(LessonTable.sectionId, CourseSectionTable.id), wherePublicLessons)
)
.where(
and(
eq(LessonTable.id, lesson.id),
eq(UserCourseAccessTable.userId, userId)
)
)
.limit(1)
return data != null && data.courseId != null
}
export const wherePublicLessons = or(
eq(LessonTable.status, "public"),
eq(LessonTable.status, "preview")
)
================================================
FILE: src/features/lessons/permissions/userLessonComplete.ts
================================================
import { db } from "@/drizzle/db"
import {
CourseSectionTable,
CourseTable,
LessonTable,
UserCourseAccessTable,
} from "@/drizzle/schema"
import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections"
import { and, eq } from "drizzle-orm"
import { wherePublicLessons } from "./lessons"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import { getUserCourseAccessUserTag } from "@/features/courses/db/cache/userCourseAccess"
import { getLessonIdTag } from "../db/cache/lessons"
export async function canUpdateUserLessonCompleteStatus(
user: { userId: string | undefined },
lessonId: string
) {
"use cache"
cacheTag(getLessonIdTag(lessonId))
if (user.userId == null) return false
cacheTag(getUserCourseAccessUserTag(user.userId))
const [courseAccess] = await db
.select({ courseId: CourseTable.id })
.from(UserCourseAccessTable)
.innerJoin(CourseTable, eq(CourseTable.id, UserCourseAccessTable.courseId))
.innerJoin(
CourseSectionTable,
and(
eq(CourseSectionTable.courseId, CourseTable.id),
wherePublicCourseSections
)
)
.innerJoin(
LessonTable,
and(eq(LessonTable.sectionId, CourseSectionTable.id), wherePublicLessons)
)
.where(
and(
eq(LessonTable.id, lessonId),
eq(UserCourseAccessTable.userId, user.userId)
)
)
.limit(1)
return courseAccess != null
}
================================================
FILE: src/features/lessons/schemas/lessons.ts
================================================
import { lessonStatusEnum } from "@/drizzle/schema"
import { z } from "zod"
export const lessonSchema = z.object({
name: z.string().min(1, "Required"),
sectionId: z.string().min(1, "Required"),
status: z.enum(lessonStatusEnum.enumValues),
youtubeVideoId: z.string().min(1, "Required"),
description: z
.string()
.transform(v => (v === "" ? null : v))
.nullable(),
})
================================================
FILE: src/features/products/actions/products.ts
================================================
"use server"
import { z } from "zod"
import {
insertProduct,
updateProduct as updateProductDb,
deleteProduct as deleteProductDb,
} from "@/features/products/db/products"
import { redirect } from "next/navigation"
import {
canCreateProducts,
canDeleteProducts,
canUpdateProducts,
} from "../permissions/products"
import { getCurrentUser } from "@/services/clerk"
import { productSchema } from "../schema/products"
export async function createProduct(unsafeData: z.infer) {
const { success, data } = productSchema.safeParse(unsafeData)
if (!success || !canCreateProducts(await getCurrentUser())) {
return { error: true, message: "There was an error creating your product" }
}
await insertProduct(data)
redirect("/admin/products")
}
export async function updateProduct(
id: string,
unsafeData: z.infer
) {
const { success, data } = productSchema.safeParse(unsafeData)
if (!success || !canUpdateProducts(await getCurrentUser())) {
return { error: true, message: "There was an error updating your product" }
}
await updateProductDb(id, data)
redirect("/admin/products")
}
export async function deleteProduct(id: string) {
if (!canDeleteProducts(await getCurrentUser())) {
return { error: true, message: "Error deleting your product" }
}
await deleteProductDb(id)
return { error: false, message: "Successfully deleted your product" }
}
================================================
FILE: src/features/products/components/ProductCard.tsx
================================================
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { formatPrice } from "@/lib/formatters"
import { getUserCoupon } from "@/lib/userCountryHeader"
import Image from "next/image"
import Link from "next/link"
import { Suspense } from "react"
export function ProductCard({
id,
imageUrl,
name,
priceInDollars,
description,
}: {
id: string
imageUrl: string
name: string
priceInDollars: number
description: string
}) {
return (
{name}
{description}
)
}
async function Price({ price }: { price: number }) {
const coupon = await getUserCoupon()
if (price === 0 || coupon == null) {
return formatPrice(price)
}
return (
{formatPrice(price)}
{formatPrice(price * (1 - coupon.discountPercentage))}
)
}
================================================
FILE: src/features/products/components/ProductForm.tsx
================================================
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { RequiredLabelIcon } from "@/components/RequiredLabelIcon"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
import { actionToast } from "@/hooks/use-toast"
import { productSchema } from "../schema/products"
import { ProductStatus, productStatuses } from "@/drizzle/schema"
import { createProduct, updateProduct } from "../actions/products"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { MultiSelect } from "@/components/ui/custom/multi-select"
export function ProductForm({
product,
courses,
}: {
product?: {
id: string
name: string
description: string
priceInDollars: number
imageUrl: string
status: ProductStatus
courseIds: string[]
}
courses: {
id: string
name: string
}[]
}) {
const form = useForm>({
resolver: zodResolver(productSchema),
defaultValues: product ?? {
name: "",
description: "",
courseIds: [],
imageUrl: "",
priceInDollars: 0,
status: "private",
},
})
async function onSubmit(values: z.infer) {
const action =
product == null ? createProduct : updateProduct.bind(null, product.id)
const data = await action(values)
actionToast({ actionData: data })
}
return (
)
}
================================================
FILE: src/features/products/components/ProductTable.tsx
================================================
import { ActionButton } from "@/components/ActionButton"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { ProductStatus } from "@/drizzle/schema"
import { formatPlural, formatPrice } from "@/lib/formatters"
import { EyeIcon, LockIcon, Trash2Icon } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { deleteProduct } from "../actions/products"
export function ProductTable({
products,
}: {
products: {
id: string
name: string
description: string
imageUrl: string
priceInDollars: number
status: ProductStatus
coursesCount: number
customersCount: number
}[]
}) {
return (
{formatPlural(products.length, {
singular: "product",
plural: "products",
})}
Customers
Status
Actions
{products.map(product => (
{product.name}
{formatPlural(product.coursesCount, {
singular: "course",
plural: "courses",
})}{" "}
• {formatPrice(product.priceInDollars)}
{product.customersCount}
{getStatusIcon(product.status)} {product.status}
))}
)
}
function getStatusIcon(status: ProductStatus) {
const Icon = {
public: EyeIcon,
private: LockIcon,
}[status]
return
}
================================================
FILE: src/features/products/db/cache.ts
================================================
import { getGlobalTag, getIdTag } from "@/lib/dataCache"
import { revalidateTag } from "next/cache"
export function getProductGlobalTag() {
return getGlobalTag("products")
}
export function getProductIdTag(id: string) {
return getIdTag("products", id)
}
export function revalidateProductCache(id: string) {
revalidateTag(getProductGlobalTag())
revalidateTag(getProductIdTag(id))
}
================================================
FILE: src/features/products/db/products.ts
================================================
import { and, eq, isNull } from "drizzle-orm"
import { db } from "@/drizzle/db"
import { revalidateProductCache } from "./cache"
import {
CourseProductTable,
ProductTable,
PurchaseTable,
} from "@/drizzle/schema"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import { getPurchaseUserTag } from "@/features/purchases/db/cache"
export async function userOwnsProduct({
userId,
productId,
}: {
userId: string
productId: string
}) {
"use cache"
cacheTag(getPurchaseUserTag(userId))
const existingPurchase = await db.query.PurchaseTable.findFirst({
where: and(
eq(PurchaseTable.productId, productId),
eq(PurchaseTable.userId, userId),
isNull(PurchaseTable.refundedAt)
),
})
return existingPurchase != null
}
export async function insertProduct(
data: typeof ProductTable.$inferInsert & { courseIds: string[] }
) {
const newProduct = await db.transaction(async trx => {
const [newProduct] = await trx.insert(ProductTable).values(data).returning()
if (newProduct == null) {
trx.rollback()
throw new Error("Failed to create product")
}
await trx.insert(CourseProductTable).values(
data.courseIds.map(courseId => ({
productId: newProduct.id,
courseId,
}))
)
return newProduct
})
revalidateProductCache(newProduct.id)
return newProduct
}
export async function updateProduct(
id: string,
data: Partial & { courseIds: string[] }
) {
const updatedProduct = await db.transaction(async trx => {
const [updatedProduct] = await trx
.update(ProductTable)
.set(data)
.where(eq(ProductTable.id, id))
.returning()
if (updatedProduct == null) {
trx.rollback()
throw new Error("Failed to create product")
}
await trx
.delete(CourseProductTable)
.where(eq(CourseProductTable.productId, updatedProduct.id))
await trx.insert(CourseProductTable).values(
data.courseIds.map(courseId => ({
productId: updatedProduct.id,
courseId,
}))
)
return updatedProduct
})
revalidateProductCache(updatedProduct.id)
return updatedProduct
}
export async function deleteProduct(id: string) {
const [deletedProduct] = await db
.delete(ProductTable)
.where(eq(ProductTable.id, id))
.returning()
if (deletedProduct == null) throw new Error("Failed to delete product")
revalidateProductCache(deletedProduct.id)
return deletedProduct
}
================================================
FILE: src/features/products/permissions/products.ts
================================================
import { ProductTable, UserRole } from "@/drizzle/schema"
import { eq } from "drizzle-orm"
export function canCreateProducts({ role }: { role: UserRole | undefined }) {
return role === "admin"
}
export function canUpdateProducts({ role }: { role: UserRole | undefined }) {
return role === "admin"
}
export function canDeleteProducts({ role }: { role: UserRole | undefined }) {
return role === "admin"
}
export const wherePublicProducts = eq(ProductTable.status, "public")
================================================
FILE: src/features/products/schema/products.ts
================================================
import { productStatuses } from "@/drizzle/schema"
import { z } from "zod"
export const productSchema = z.object({
name: z.string().min(1, "Required"),
priceInDollars: z.number().int().nonnegative(),
description: z.string().min(1, "Required"),
imageUrl: z.union([
z.string().url("Invalid url"),
z.string().startsWith("/", "Invalid url"),
]),
status: z.enum(productStatuses),
courseIds: z.array(z.string()).min(1, "At least one course is required"),
})
================================================
FILE: src/features/purchases/actions/purchases.ts
================================================
"use server"
import { stripeServerClient } from "@/services/stripe/stripeServer"
import { canRefundPurchases } from "../permissions/products"
import { getCurrentUser } from "@/services/clerk"
import { db } from "@/drizzle/db"
import { updatePurchase } from "../db/purchases"
import { revokeUserCourseAccess } from "@/features/courses/db/userCourseAcccess"
export async function refundPurchase(id: string) {
if (!canRefundPurchases(await getCurrentUser())) {
return {
error: true,
message: "There was an error refunding this purchase",
}
}
const data = await db.transaction(async trx => {
const refundedPurchase = await updatePurchase(
id,
{ refundedAt: new Date() },
trx
)
const session = await stripeServerClient.checkout.sessions.retrieve(
refundedPurchase.stripeSessionId
)
if (session.payment_intent == null) {
trx.rollback()
return {
error: true,
message: "There was an error refunding this purchase",
}
}
try {
await stripeServerClient.refunds.create({
payment_intent:
typeof session.payment_intent === "string"
? session.payment_intent
: session.payment_intent.id,
})
await revokeUserCourseAccess(refundedPurchase, trx)
} catch {
trx.rollback()
return {
error: true,
message: "There was an error refunding this purchase",
}
}
})
return data ?? { error: false, message: "Successfully refunded purchase" }
}
================================================
FILE: src/features/purchases/components/PurchaseTable.tsx
================================================
import { ActionButton } from "@/components/ActionButton"
import {
SkeletonArray,
SkeletonButton,
SkeletonText,
} from "@/components/Skeleton"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { formatDate, formatPlural, formatPrice } from "@/lib/formatters"
import Image from "next/image"
import { refundPurchase } from "../actions/purchases"
export function PurchaseTable({
purchases,
}: {
purchases: {
id: string
pricePaidInCents: number
createdAt: Date
refundedAt: Date | null
productDetails: {
name: string
imageUrl: string
}
user: {
name: string
}
}[]
}) {
return (
{" "}
{formatPlural(purchases.length, {
singular: "sale",
plural: "sales",
})}
Customer Name
Amount
Actions
{purchases.map(purchase => (
{purchase.productDetails.name}
{formatDate(purchase.createdAt)}
{purchase.user.name}
{purchase.refundedAt ? (
Refunded
) : (
formatPrice(purchase.pricePaidInCents / 100)
)}
{purchase.refundedAt == null && purchase.pricePaidInCents > 0 && (
Refund
)}
))}
)
}
export function UserPurchaseTableSkeleton() {
return (
)
}
================================================
FILE: src/features/purchases/components/UserPurchaseTable.tsx
================================================
import {
SkeletonArray,
SkeletonButton,
SkeletonText,
} from "@/components/Skeleton"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { formatDate, formatPrice } from "@/lib/formatters"
import Image from "next/image"
import Link from "next/link"
export function UserPurchaseTable({
purchases,
}: {
purchases: {
id: string
pricePaidInCents: number
createdAt: Date
refundedAt: Date | null
productDetails: {
name: string
imageUrl: string
}
}[]
}) {
return (
Product
Amount
Actions
{purchases.map(purchase => (
{purchase.productDetails.name}
{formatDate(purchase.createdAt)}
{purchase.refundedAt ? (
Refunded
) : (
formatPrice(purchase.pricePaidInCents / 100)
)}
))}
)
}
export function UserPurchaseTableSkeleton() {
return (
)
}
================================================
FILE: src/features/purchases/db/cache.ts
================================================
import { getGlobalTag, getIdTag, getUserTag } from "@/lib/dataCache"
import { revalidateTag } from "next/cache"
export function getPurchaseGlobalTag() {
return getGlobalTag("purchases")
}
export function getPurchaseIdTag(id: string) {
return getIdTag("purchases", id)
}
export function getPurchaseUserTag(userId: string) {
return getUserTag("purchases", userId)
}
export function revalidatePurchaseCache({
id,
userId,
}: {
id: string
userId: string
}) {
revalidateTag(getPurchaseGlobalTag())
revalidateTag(getPurchaseIdTag(id))
revalidateTag(getPurchaseUserTag(userId))
}
================================================
FILE: src/features/purchases/db/purchases.ts
================================================
import { db } from "@/drizzle/db"
import { PurchaseTable } from "@/drizzle/schema"
import { revalidatePurchaseCache } from "./cache"
import { eq } from "drizzle-orm"
export async function insertPurchase(
data: typeof PurchaseTable.$inferInsert,
trx: Omit = db
) {
const details = data.productDetails
const [newPurchase] = await trx
.insert(PurchaseTable)
.values({
...data,
productDetails: {
name: details.name,
description: details.description,
imageUrl: details.imageUrl,
},
})
.onConflictDoNothing()
.returning()
if (newPurchase != null) revalidatePurchaseCache(newPurchase)
return newPurchase
}
export async function updatePurchase(
id: string,
data: Partial,
trx: Omit = db
) {
const details = data.productDetails
const [updatedPurchase] = await trx
.update(PurchaseTable)
.set({
...data,
productDetails: details
? {
name: details.name,
description: details.description,
imageUrl: details.imageUrl,
}
: undefined,
})
.where(eq(PurchaseTable.id, id))
.returning()
if (updatedPurchase == null) throw new Error("Failed to update purchase")
revalidatePurchaseCache(updatedPurchase)
return updatedPurchase
}
================================================
FILE: src/features/purchases/permissions/products.ts
================================================
import { UserRole } from "@/drizzle/schema"
export function canRefundPurchases({ role }: { role: UserRole | undefined }) {
return role === "admin"
}
================================================
FILE: src/features/users/db/cache.ts
================================================
import { getGlobalTag, getIdTag } from "@/lib/dataCache"
import { revalidateTag } from "next/cache"
export function getUserGlobalTag() {
return getGlobalTag("users")
}
export function getUserIdTag(id: string) {
return getIdTag("users", id)
}
export function revalidateUserCache(id: string) {
revalidateTag(getUserGlobalTag())
revalidateTag(getUserIdTag(id))
}
================================================
FILE: src/features/users/db/users.ts
================================================
import { db } from "@/drizzle/db"
import { UserTable } from "@/drizzle/schema"
import { eq } from "drizzle-orm"
import { revalidateUserCache } from "./cache"
export async function insertUser(data: typeof UserTable.$inferInsert) {
const [newUser] = await db
.insert(UserTable)
.values(data)
.returning()
.onConflictDoUpdate({
target: [UserTable.clerkUserId],
set: data,
})
if (newUser == null) throw new Error("Failed to create user")
revalidateUserCache(newUser.id)
return newUser
}
export async function updateUser(
{ clerkUserId }: { clerkUserId: string },
data: Partial
) {
const [updatedUser] = await db
.update(UserTable)
.set(data)
.where(eq(UserTable.clerkUserId, clerkUserId))
.returning()
if (updatedUser == null) throw new Error("Failed to update user")
revalidateUserCache(updatedUser.id)
return updatedUser
}
export async function deleteUser({ clerkUserId }: { clerkUserId: string }) {
const [deletedUser] = await db
.update(UserTable)
.set({
deletedAt: new Date(),
email: "redacted@deleted.com",
name: "Deleted User",
clerkUserId: "deleted",
imageUrl: null,
})
.where(eq(UserTable.clerkUserId, clerkUserId))
.returning()
if (deletedUser == null) throw new Error("Failed to delete user")
revalidateUserCache(deletedUser.id)
return deletedUser
}
================================================
FILE: src/hooks/use-toast.ts
================================================
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = {
ADD_TOAST: "ADD_TOAST"
UPDATE_TOAST: "UPDATE_TOAST"
DISMISS_TOAST: "DISMISS_TOAST"
REMOVE_TOAST: "REMOVE_TOAST"
}
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map(t =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach(toast => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map(t =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter(t => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach(listener => {
listener(memoryState)
})
}
type Toast = Omit
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: open => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
function actionToast({
actionData,
...props
}: Omit & {
actionData: { error: boolean; message: string }
}) {
return toast({
...props,
title: actionData.error ? "Error" : "Success",
description: actionData.message,
variant: actionData.error ? "destructive" : "default",
})
}
export { useToast, toast, actionToast }
================================================
FILE: src/lib/dataCache.ts
================================================
type CACHE_TAG =
| "products"
| "users"
| "courses"
| "userCourseAccess"
| "courseSections"
| "lessons"
| "purchases"
| "userLessonComplete"
export function getGlobalTag(tag: CACHE_TAG) {
return `global:${tag}` as const
}
export function getIdTag(tag: CACHE_TAG, id: string) {
return `id:${id}-${tag}` as const
}
export function getUserTag(tag: CACHE_TAG, userId: string) {
return `user:${userId}-${tag}` as const
}
export function getCourseTag(tag: CACHE_TAG, courseId: string) {
return `course:${courseId}-${tag}` as const
}
================================================
FILE: src/lib/formatters.ts
================================================
export function formatPlural(
count: number,
{ singular, plural }: { singular: string; plural: string },
{ includeCount = true } = {}
) {
const word = count === 1 ? singular : plural
return includeCount ? `${count} ${word}` : word
}
export function formatPrice(amount: number, { showZeroAsNumber = false } = {}) {
const formatter = new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
})
if (amount === 0 && !showZeroAsNumber) return "Free"
return formatter.format(amount)
}
const DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
})
export function formatDate(date: Date) {
return DATE_FORMATTER.format(date)
}
export function formatNumber(
number: number,
options?: Intl.NumberFormatOptions
) {
const formatter = new Intl.NumberFormat(undefined, options)
return formatter.format(number)
}
================================================
FILE: src/lib/sumArray.ts
================================================
export function sumArray(array: T[], func: (item: T) => number) {
return array.reduce((acc, item) => acc + func(item), 0)
}
================================================
FILE: src/lib/userCountryHeader.ts
================================================
import { pppCoupons } from "@/data/pppCoupons"
import { headers } from "next/headers"
const COUNTRY_HEADER_KEY = "x-user-country"
export function setUserCountryHeader(
headers: Headers,
country: string | undefined
) {
if (country == null) {
headers.delete(COUNTRY_HEADER_KEY)
} else {
headers.set(COUNTRY_HEADER_KEY, country)
}
}
async function getUserCountry() {
const head = await headers()
return head.get(COUNTRY_HEADER_KEY)
}
export async function getUserCoupon() {
const country = await getUserCountry()
if (country == null) return
const coupon = pppCoupons.find(coupon =>
coupon.countryCodes.includes(country)
)
if (coupon == null) return
return {
stripeCouponId: coupon.stripeCouponId,
discountPercentage: coupon.discountPercentage,
}
}
================================================
FILE: src/lib/utils.ts
================================================
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
================================================
FILE: src/middleware.ts
================================================
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"
import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/next"
import { env } from "./data/env/server"
import { setUserCountryHeader } from "./lib/userCountryHeader"
import { NextResponse } from "next/server"
const isPublicRoute = createRouteMatcher([
"/",
"/sign-in(.*)",
"/sign-up(.*)",
"/api(.*)",
"/courses/:courseId/lessons/:lessonId",
"/products(.*)",
])
const isAdminRoute = createRouteMatcher(["/admin(.*)"])
const aj = arcjet({
key: env.ARCJET_KEY,
rules: [
shield({ mode: "LIVE" }),
detectBot({
mode: "LIVE",
allow: ["CATEGORY:SEARCH_ENGINE", "CATEGORY:MONITOR", "CATEGORY:PREVIEW"],
}),
slidingWindow({
mode: "LIVE",
interval: "1m",
max: 100,
}),
],
})
export default clerkMiddleware(async (auth, req) => {
const decision = await aj.protect(
env.TEST_IP_ADDRESS
? { ...req, ip: env.TEST_IP_ADDRESS, headers: req.headers }
: req
)
if (decision.isDenied()) {
return new NextResponse(null, { status: 403 })
}
if (isAdminRoute(req)) {
const user = await auth.protect()
if (user.sessionClaims.role !== "admin") {
return new NextResponse(null, { status: 404 })
}
}
if (!isPublicRoute(req)) {
await auth.protect()
}
if (!decision.ip.isVpn() && !decision.ip.isProxy()) {
const headers = new Headers(req.headers)
setUserCountryHeader(headers, decision.ip.country)
return NextResponse.next({ request: { headers } })
}
})
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
// Always run for API routes
"/(api|trpc)(.*)",
],
}
================================================
FILE: src/permissions/general.ts
================================================
import { UserRole } from "@/drizzle/schema"
export function canAccessAdminPages({ role }: { role: UserRole | undefined }) {
return role === "admin"
}
================================================
FILE: src/services/clerk.ts
================================================
import { db } from "@/drizzle/db"
import { UserRole, UserTable } from "@/drizzle/schema"
import { getUserIdTag } from "@/features/users/db/cache"
import { auth, clerkClient } from "@clerk/nextjs/server"
import { eq } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import { redirect } from "next/navigation"
const client = await clerkClient()
export async function getCurrentUser({ allData = false } = {}) {
const { userId, sessionClaims, redirectToSignIn } = await auth()
if (userId != null && sessionClaims.dbId == null) {
redirect("/api/clerk/syncUsers")
}
return {
clerkUserId: userId,
userId: sessionClaims?.dbId,
role: sessionClaims?.role,
user:
allData && sessionClaims?.dbId != null
? await getUser(sessionClaims.dbId)
: undefined,
redirectToSignIn,
}
}
export function syncClerkUserMetadata(user: {
id: string
clerkUserId: string
role: UserRole
}) {
return client.users.updateUserMetadata(user.clerkUserId, {
publicMetadata: {
dbId: user.id,
role: user.role,
},
})
}
async function getUser(id: string) {
"use cache"
cacheTag(getUserIdTag(id))
console.log("Called")
return db.query.UserTable.findFirst({
where: eq(UserTable.id, id),
})
}
================================================
FILE: src/services/stripe/actions/stripe.ts
================================================
"use server"
import { getUserCoupon } from "@/lib/userCountryHeader"
import { stripeServerClient } from "../stripeServer"
import { env } from "@/data/env/client"
export async function getClientSessionSecret(
product: {
priceInDollars: number
name: string
imageUrl: string
description: string
id: string
},
user: { email: string; id: string }
) {
const coupon = await getUserCoupon()
const discounts = coupon ? [{ coupon: coupon.stripeCouponId }] : undefined
const session = await stripeServerClient.checkout.sessions.create({
line_items: [
{
quantity: 1,
price_data: {
currency: "usd",
product_data: {
name: product.name,
images: [
new URL(product.imageUrl, env.NEXT_PUBLIC_SERVER_URL).href,
],
description: product.description,
},
unit_amount: product.priceInDollars * 100,
},
},
],
ui_mode: "embedded",
mode: "payment",
return_url: `${env.NEXT_PUBLIC_SERVER_URL}/api/webhooks/stripe?stripeSessionId={CHECKOUT_SESSION_ID}`,
customer_email: user.email,
payment_intent_data: {
receipt_email: user.email,
},
discounts,
metadata: {
productId: product.id,
userId: user.id,
},
})
if (session.client_secret == null) throw new Error("Client secret is null")
return session.client_secret
}
================================================
FILE: src/services/stripe/components/StripeCheckoutForm.tsx
================================================
"use client"
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from "@stripe/react-stripe-js"
import { stripeClientPromise } from "../stripeClient"
import { getClientSessionSecret } from "../actions/stripe"
export function StripeCheckoutForm({
product,
user,
}: {
product: {
priceInDollars: number
name: string
id: string
imageUrl: string
description: string
}
user: {
email: string
id: string
}
}) {
return (
)
}
================================================
FILE: src/services/stripe/stripeClient.ts
================================================
import { env } from "@/data/env/client"
import { loadStripe } from "@stripe/stripe-js"
export const stripeClientPromise = loadStripe(
env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
)
================================================
FILE: src/services/stripe/stripeServer.ts
================================================
import { env } from "@/data/env/server"
import Stripe from "stripe"
export const stripeServerClient = new Stripe(env.STRIPE_SECRET_KEY)
================================================
FILE: tailwind.config.ts
================================================
import type { Config } from "tailwindcss"
import containerQueries from "@tailwindcss/container-queries"
export default {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/features/**/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
sm: '1500px'
}
},
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [require("tailwindcss-animate"), containerQueries],
} satisfies Config
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"noUncheckedIndexedAccess": true,
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
|