diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index dd4ac1b..3d3134e 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -9,7 +9,7 @@ export default function Dashboard() { const { data } = trpc.user.getCurrent.useQuery(); const goToMap = () => router.push("/"); - const goToAdmin = () => router.push("/admin"); + const goToAdmin = () => router.push("/admin"); const handleSignOut = async () => { await signOut(); @@ -18,15 +18,22 @@ export default function Dashboard() { return (
-
- {/* HEADER SECTION */}
- + @@ -39,32 +46,61 @@ export default function Dashboard() {
{/* ACTIONS PORTAL */} -
- {/* Primary Action: Go to Map */} - - - {/* Secondary Action: Admin */} - -
+
+ {/* Primary Action: Go to Map */} + + + {/* Secondary Action: Admin */} + +
{/* FOOTER & SIGN OUT */}
-
-
); -} \ No newline at end of file +} diff --git a/apps/web/app/sign-in/page.tsx b/apps/web/app/sign-in/page.tsx index 762a627..048bfbb 100644 --- a/apps/web/app/sign-in/page.tsx +++ b/apps/web/app/sign-in/page.tsx @@ -1,51 +1,103 @@ -'use client' +"use client"; -import { authClient } from '@/lib/auth-client' +import { authClient } from "@/lib/auth-client"; +import { useRouter } from "next/navigation"; export default function SignIn() { - const handleLogin = async () => { - await authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' }) - } - - return ( -
- - {/* BACKGROUND GLOW */} -
- - {/* LOGIN CARD */} -
- - {/* HEADER SECTION */} -
-
- - - -
- -

UP WayPoint

-
- - {/* AUTH BUTTON */} - - - {/* FOOTER */} -
- Source code available here
- v1.2.0 -
-
- - - -
- ) -} \ No newline at end of file + + ); +} diff --git a/apps/web/components/ExpandedPinView.tsx b/apps/web/components/ExpandedPinView.tsx index 0b4c8cc..c395136 100644 --- a/apps/web/components/ExpandedPinView.tsx +++ b/apps/web/components/ExpandedPinView.tsx @@ -2,7 +2,12 @@ import { getFilterColor } from "@/components/TopBar"; import { trpc } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; import Image from "next/image"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; + interface ExpandedPinViewProps { pinId: string; onClose: () => void; @@ -10,60 +15,22 @@ interface ExpandedPinViewProps { type Comment = { id: string; - author: string; - timeAgo: string; - text: string; - rating?: number; - upvotes: number; - replies?: Comment[]; + createdAt: string; + updatedAt: string; + ownerId: string; + pinId: string; + message: string; + parentId: string | null; + deletedAt: string | null; + replies: Comment[]; + authorName: string; }; -const MOCK_THREADS: Comment[] = [ - { - id: "c1", - author: "user001", - timeAgo: "2 hours ago", - rating: 5, - upvotes: 124, - text: "nice!", - replies: [ - { - id: "c1-1", - author: "user002", - timeAgo: "1 hour ago", - upvotes: 15, - text: "wow i love it", - replies: [ - { - id: "c1-1-1", - author: "user003", - timeAgo: "45 mins ago", - upvotes: 8, - text: "me too!", - replies: [ - { - id: "c1-1-1-1", - author: "user004", - timeAgo: "10 mins ago", - upvotes: 2, - text: "woah!!!", - }, - ], - }, - ], - }, - ], - }, - { - id: "c2", - author: "user005", - timeAgo: "1 day ago", - rating: 4, - upvotes: 89, - text: "k lang", - replies: [], - }, -]; +const commentSchema = z.object({ + message: z.string(), +}); + +type commentSchemaType = z.infer; const CommentNode = ({ comment, @@ -72,24 +39,43 @@ const CommentNode = ({ comment: Comment; depth: number; }) => { + const utils = trpc.useUtils(); + const createComment = trpc.comment.create.useMutation({ + onSuccess(output) { + utils.pin.getById.invalidate(); + setIsReplying(false); + }, + }); + const formMethods = useForm({ resolver: zodResolver(commentSchema) }); + const [isReplying, setIsReplying] = useState(false); if (depth > 3) return null; + function onSubmit(data: commentSchemaType) { + createComment.mutate({ + message: data.message, + pinId: comment.pinId, + parentId: comment.id, + }); + } + return (
0 ? "is-reply" : ""}`}>
- {comment.author} - {comment.timeAgo} - {depth === 0 && comment.rating && ( + {comment.authorName} + + {new Date(comment.createdAt).toLocaleString("default")} + + {/* {depth === 0 && comment.rating && ( {"★".repeat(comment.rating)} {comment.rating}/5 - )} + )} */}
-

{comment.text}

+

{comment.message}

- - + */} + {!isReplying ? ( + depth < 3 && ( + + ) + ) : ( +
+ + + +
+ )}
{comment.replies && comment.replies.length > 0 && ( @@ -141,7 +143,10 @@ export function ExpandedPinView({ pinId, onClose }: ExpandedPinViewProps) { { id: pinId }, { refetchOnWindowFocus: false }, ); - const color = getFilterColor(pin?.pinTags[0]?.tag.title || ""); + + const color = getFilterColor( + pin?.pinTags ? pin.pinTags[0]?.tag.title || "" : "", + ); return ( // biome-ignore lint/a11y/noStaticElementInteractions: @@ -155,7 +160,7 @@ export function ExpandedPinView({ pinId, onClose }: ExpandedPinViewProps) {
- {pin?.pinTags.map((pt) => pt.tag.title).join(", ")} + {pin?.pinTags?.map((pt) => pt.tag.title).join(", ")}

{pin?.title}

@@ -178,7 +183,7 @@ export function ExpandedPinView({ pinId, onClose }: ExpandedPinViewProps) { {/* HORIZONTAL BENTO GALLERY */}
- {pin?.images.map((img) => ( + {pin?.images?.map((img) => (
{/* PIN ID - {pin?.id.padStart(7, "0")} + {pin?.id?.padStart(7, "0")}
@@ -222,7 +227,7 @@ export function ExpandedPinView({ pinId, onClose }: ExpandedPinViewProps) {
COORDINATES (LAT, LNG) - {pin?.latitude.toFixed(6)}, {pin?.longitude.toFixed(6)} + {pin?.latitude?.toFixed(6)}, {pin?.longitude?.toFixed(6)}
@@ -266,7 +271,7 @@ export function ExpandedPinView({ pinId, onClose }: ExpandedPinViewProps) {

FORUM

- {MOCK_THREADS.map((thread) => ( + {pin?.comments?.map((thread) => ( ))}
diff --git a/apps/web/components/PinDetailsCard.tsx b/apps/web/components/PinDetailsCard.tsx index 3729a2c..13f4157 100644 --- a/apps/web/components/PinDetailsCard.tsx +++ b/apps/web/components/PinDetailsCard.tsx @@ -22,7 +22,9 @@ export function PinDetailsCard({ { id: pinId }, { refetchOnWindowFocus: false }, ); - const color = getFilterColor(pin?.pinTags[0]?.tag.title || ""); + const color = getFilterColor( + pin?.pinTags ? pin?.pinTags[0]?.tag.title || "" : "", + ); return (
@@ -31,7 +33,7 @@ export function PinDetailsCard({

{pin?.title}

- {pin?.pinTags.map((pt) => pt.tag.title).join(", ")} + {pin?.pinTags?.map((pt) => pt.tag.title).join(", ")}
diff --git a/apps/web/components/TopBar.tsx b/apps/web/components/TopBar.tsx index 1c49abe..5545488 100644 --- a/apps/web/components/TopBar.tsx +++ b/apps/web/components/TopBar.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/navigation"; import { trpc } from "@/lib/trpc"; +import { useSession } from "@/lib/auth-client"; export type FilterType = "all" | "academic" | "food" | "social" | "utility"; @@ -21,10 +22,10 @@ export function TopBar({ onSearchChange, }: TopBarProps) { const router = useRouter(); - const { data: user } = trpc.user.getCurrent.useQuery(); + const { data: sessionData } = useSession(); const handleProfileClick = () => { - if (user) { + if (sessionData?.user) { router.push("/dashboard"); } else { router.push("/sign-in"); @@ -121,14 +122,18 @@ export function TopBar({ type="button" className="icon-button profile-btn" onClick={handleProfileClick} - title={user ? "Access Dashboard" : "System Login"} + title={sessionData?.user ? "Access Dashboard" : "System Login"} > diff --git a/packages/api/src/routers/comment.router.ts b/packages/api/src/routers/comment.router.ts new file mode 100644 index 0000000..8f542c1 --- /dev/null +++ b/packages/api/src/routers/comment.router.ts @@ -0,0 +1,24 @@ +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; +import { router, userProcedure } from "../trpc"; +import z from "zod"; + +export const commentRouter = router({ + create: userProcedure + .input( + z.object({ + message: z.string(), + pinId: z.string(), + parentId: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + return ctx.services.comment.create({ + ...input, + ownerId: ctx.user.id, + }); + }), +}); + +type CommentRouter = typeof commentRouter; +export type CommentRouterOutputs = inferRouterOutputs; +export type CommentRouterInputs = inferRouterInputs; diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index ab0c4b0..582eaec 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -2,11 +2,13 @@ import { router } from "../trpc"; import { userRouter } from "./user.router"; import { pinRouter } from "./pins.router"; import { tagRouter } from "./tag.router"; +import { commentRouter } from "./comment.router"; export const appRouter = router({ user: userRouter, pin: pinRouter, tag: tagRouter, + comment: commentRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/api/src/services/comment.service.ts b/packages/api/src/services/comment.service.ts new file mode 100644 index 0000000..14e2d8d --- /dev/null +++ b/packages/api/src/services/comment.service.ts @@ -0,0 +1,50 @@ +import type { + CommentRepository, + Database, + Comment, + CommentWithReplies, + CreateComment, + UserRepository, +} from "@repo/db"; + +function nestComments(rows: Comment[]): CommentWithReplies[] { + const map = new Map(); + const roots: CommentWithReplies[] = []; + + rows.forEach((row) => { + map.set(row.id, { ...row, replies: [] }); + }); + + rows.forEach((row) => { + if (row.parentId) { + map.get(row.parentId)?.replies.push(map.get(row.id)); + } else { + roots.push(map.get(row.id)); + console.log(row.message, row.parentId); + } + }); + + return roots; +} + +export function makeCommentService( + repositories: { comment: CommentRepository; user: UserRepository }, + db: Database, +) { + async function getByPinId(pinId: string) { + const rows = await repositories.comment.getByPinId(pinId); + + return nestComments(rows); + } + + async function create(data: CreateComment) { + return await repositories.comment.create(data); + } + + return { + create, + getByPinId, + }; +} + +export type CommentService = ReturnType; diff --git a/packages/api/src/services/index.ts b/packages/api/src/services/index.ts index 726d42a..c0b58f9 100644 --- a/packages/api/src/services/index.ts +++ b/packages/api/src/services/index.ts @@ -2,10 +2,15 @@ import { db, repositories } from "@repo/db"; import { makeUserService } from "./user.service"; import { makePinService } from "./pins.service"; import { makeTagService } from "./tag.service"; +import { makeCommentService } from "./comment.service"; + +const commentService = makeCommentService(repositories, db); + export const services = { user: makeUserService(repositories, db), - pin: makePinService(repositories, db), + pin: makePinService(repositories, { comment: commentService }, db), tag: makeTagService(repositories, db), + comment: commentService, }; export type Services = typeof services; diff --git a/packages/api/src/services/pins.service.ts b/packages/api/src/services/pins.service.ts index 0b24bc0..6efb339 100644 --- a/packages/api/src/services/pins.service.ts +++ b/packages/api/src/services/pins.service.ts @@ -6,6 +6,7 @@ import type { PinTagsRepository, } from "@repo/db"; import { TRPCError } from "@trpc/server"; +import type { CommentService } from "./comment.service"; export function makePinService( repositories: { @@ -13,6 +14,7 @@ export function makePinService( pinTags: PinTagsRepository; pinImages: PinImagesRepository; }, + services: { comment: CommentService }, db: Database, ) { async function getAll() { @@ -20,7 +22,9 @@ export function makePinService( } async function getById(id: string) { - return await repositories.pin.getById(id); + const pin = await repositories.pin.getById(id); + const comments = await services.comment.getByPinId(id); + return { ...pin, comments: comments }; } async function getByOwnerId(ownerId: string) { diff --git a/packages/db/drizzle/0004_parched_the_hand.sql b/packages/db/drizzle/0004_parched_the_hand.sql new file mode 100644 index 0000000..6f80809 --- /dev/null +++ b/packages/db/drizzle/0004_parched_the_hand.sql @@ -0,0 +1,13 @@ +CREATE TABLE "comment" ( + "id" text PRIMARY KEY NOT NULL, + "message" text NOT NULL, + "owner_id" text NOT NULL, + "parent_id" text, + "pin_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "deleted_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "comment" ADD CONSTRAINT "comment_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comment" ADD CONSTRAINT "comment_pin_id_pin_id_fk" FOREIGN KEY ("pin_id") REFERENCES "public"."pin"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0004_snapshot.json b/packages/db/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..09df96b --- /dev/null +++ b/packages/db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,737 @@ +{ + "id": "9c23bce8-9563-4e58-80f0-bc34391a646a", + "prevId": "e93be728-3719-4173-93c3-29e97073525f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment": { + "name": "comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin_id": { + "name": "pin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "comment_owner_id_user_id_fk": { + "name": "comment_owner_id_user_id_fk", + "tableFrom": "comment", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_pin_id_pin_id_fk": { + "name": "comment_pin_id_pin_id_fk", + "tableFrom": "comment", + "tableTo": "pin", + "columnsFrom": [ + "pin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pin": { + "name": "pin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PENDING_VERIFICATION'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pin_owner_id_user_id_fk": { + "name": "pin_owner_id_user_id_fk", + "tableFrom": "pin", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pin_images": { + "name": "pin_images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pin_id": { + "name": "pin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pin_images_pin_id_pin_id_fk": { + "name": "pin_images_pin_id_pin_id_fk", + "tableFrom": "pin_images", + "tableTo": "pin", + "columnsFrom": [ + "pin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pin_tags": { + "name": "pin_tags", + "schema": "", + "columns": { + "pin_id": { + "name": "pin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pin_tags_pin_id_pin_id_fk": { + "name": "pin_tags_pin_id_pin_id_fk", + "tableFrom": "pin_tags", + "tableTo": "pin", + "columnsFrom": [ + "pin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pin_tags_tag_id_tag_id_fk": { + "name": "pin_tags_tag_id_tag_id_fk", + "tableFrom": "pin_tags", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pin_tags_pin_id_tag_id_pk": { + "name": "pin_tags_pin_id_tag_id_pk", + "columns": [ + "pin_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#ffffff'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userRole": { + "name": "userRole", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "admin" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 6a999e1..1bc8178 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1773230033373, "tag": "0003_unknown_blacklash", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1773235656634, + "tag": "0004_parched_the_hand", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/db/schema.ts b/packages/db/src/db/schema.ts index 9c553a7..62c5cb0 100644 --- a/packages/db/src/db/schema.ts +++ b/packages/db/src/db/schema.ts @@ -8,6 +8,7 @@ import { index, doublePrecision, primaryKey, + integer, } from "drizzle-orm/pg-core"; import { randomUUID } from "crypto"; @@ -147,9 +148,31 @@ export const pin = pgTable("pin", { .notNull(), }); +export const comment = pgTable("comment", { + id: text("id") + .primaryKey() + .$defaultFn(() => randomUUID()), + message: text("message").notNull(), + ownerId: text("owner_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + parentId: text("parent_id"), + pinId: text("pin_id") + .notNull() + .references(() => pin.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + deletedAt: timestamp("deleted_at"), +}); + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), + pins: many(pin), + comments: many(comment), })); export const sessionRelations = relations(session, ({ one }) => ({ @@ -173,6 +196,7 @@ export const pinRelations = relations(pin, ({ one, many }) => ({ }), pinTags: many(pinTags), images: many(pinImages), + comments: many(comment), })); export const tagRelations = relations(tag, ({ many }) => ({ @@ -187,3 +211,13 @@ export const pinTagsRelations = relations(pinTags, ({ one }) => ({ export const pinImagesRelations = relations(pinImages, ({ one }) => ({ pin: one(pin, { fields: [pinImages.pinId], references: [pin.id] }), })); + +export const commentRelations = relations(comment, ({ one, many }) => ({ + owner: one(user, { fields: [comment.ownerId], references: [user.id] }), + pin: one(pin, { fields: [comment.pinId], references: [pin.id] }), + parent: one(comment, { + fields: [comment.parentId], + references: [comment.id], + }), + replies: many(comment), +})); diff --git a/packages/db/src/db/types.ts b/packages/db/src/db/types.ts index 69089cf..890b65b 100644 --- a/packages/db/src/db/types.ts +++ b/packages/db/src/db/types.ts @@ -8,6 +8,7 @@ import type { pinTags, tag, pinImages, + comment, } from "./schema"; export type Session = InferSelectModel; @@ -15,9 +16,17 @@ export type User = InferSelectModel; export type Account = InferSelectModel; export type Verification = InferSelectModel; export type Tag = InferSelectModel; +export type Comment = InferSelectModel; +export type CreateComment = InferInsertModel; +export type CommentWithReplies = Comment & { + replies: CommentWithReplies[]; + authorName: string; +}; + export type PinTags = InferSelectModel; export type PinImages = InferSelectModel; export type Pin = InferSelectModel; + export type CreatePin = InferInsertModel; export type UpdatePin = Partial; export type PinWithTags = diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index f54bbfd..d928020 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -4,6 +4,7 @@ import { makePinRepository, makeTagRepository, makePinImagesRepository, + makeCommentRepository, } from "./repositories"; import { makePinTagsRepository } from "./repositories/pinTags.repository"; @@ -13,6 +14,7 @@ export const repositories = { pinTags: makePinTagsRepository(db), tag: makeTagRepository(db), pinImages: makePinImagesRepository(db), + comment: makeCommentRepository(db), }; export * from "./db/database"; diff --git a/packages/db/src/repositories/comment.repository.ts b/packages/db/src/repositories/comment.repository.ts new file mode 100644 index 0000000..9a467ba --- /dev/null +++ b/packages/db/src/repositories/comment.repository.ts @@ -0,0 +1,68 @@ +import { sql } from "drizzle-orm"; +import type { Database } from "../db/database"; +import type { Comment, CreateComment } from "../db/types"; +import { comment } from "../db/schema"; + +export function makeCommentRepository(db: Database) { + async function create(data: CreateComment) { + const [c] = await db.insert(comment).values(data).returning(); + return c; + } + + async function getByPinId( + pinId: string, + maxDepth: number = 3, + ): Promise<(Comment & { authorName: string })[]> { + try { + const result = await db.execute(sql` + WITH RECURSIVE comment_tree AS ( + -- Base case: top-level comment (no parent) + SELECT + c.id, c.message, c.owner_id, c.parent_id, c.pin_id, c.deleted_at, c.created_at, c.updated_at, + u.name AS author_name, + 0 AS depth + FROM comment c + LEFT JOIN "user" u ON c.owner_id = u.id + WHERE pin_id = ${pinId} AND parent_id IS NULL + + UNION ALL + + -- Recursive case: get replies up to maxDepth + SELECT + c.id, c.message, c.owner_id, c.parent_id, c.pin_id, c.deleted_at, c.created_at, c.updated_at, + u.name AS author_name, + ct.depth + 1 + FROM comment c + LEFT JOIN "user" u ON c.owner_id = u.id + INNER JOIN comment_tree ct ON c.parent_id = ct.id + WHERE ct.depth < ${maxDepth} + ) + SELECT * FROM comment_tree + ORDER BY depth, created_at DESC + `); + + return result.map((row) => { + return { + id: row.id, + message: row.message, + pinId: row.pin_id, + parentId: row.parent_id, + ownerId: row.owner_id, + authorName: row.author_name, + createdAt: row.created_at, + updatedAt: row.updated_at, + deletedAt: row.deleted_at, + } as Comment & { authorName: string }; + }); + } catch (e) { + console.error("Raw DB error:", e); + throw e; + } + } + return { + create, + getByPinId, + }; +} + +export type CommentRepository = ReturnType; diff --git a/packages/db/src/repositories/index.ts b/packages/db/src/repositories/index.ts index d8a818e..7c933a1 100644 --- a/packages/db/src/repositories/index.ts +++ b/packages/db/src/repositories/index.ts @@ -3,3 +3,4 @@ export * from "./pins.repository"; export * from "./pinTags.repository"; export * from "./tag.repository"; export * from "./pinImages.repository"; +export * from "./comment.repository";