diff --git a/app/[orgId]/contests/[id]/page.tsx b/app/[orgId]/contests/[id]/page.tsx index bc12bfd..b8e65a0 100644 --- a/app/[orgId]/contests/[id]/page.tsx +++ b/app/[orgId]/contests/[id]/page.tsx @@ -245,6 +245,25 @@ export default function ContestDetailsPage() { Problems will be available when the contest starts. )} + + {shouldShowProblems() ? ( +
+

+ + + +

+
+ ) : ( +
+ Submissions will be available when the contest starts. +
+ )} diff --git a/app/[orgId]/contests/[id]/submissions/mockData.ts b/app/[orgId]/contests/[id]/submissions/mockData.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/[orgId]/contests/[id]/submissions/page.tsx b/app/[orgId]/contests/[id]/submissions/page.tsx new file mode 100644 index 0000000..80ab65e --- /dev/null +++ b/app/[orgId]/contests/[id]/submissions/page.tsx @@ -0,0 +1,12 @@ +import SubmissionsPage from "@/app/[orgId]/submissions/page"; + +export default function SubmissionsPageWrapper({ + params, +}: { + params: { + orgId: string; + id: string; + }; +}) { + return ; +} diff --git a/app/[orgId]/groups/mockData.ts b/app/[orgId]/groups/mockData.ts index 83e5d91..4b9ee9c 100644 --- a/app/[orgId]/groups/mockData.ts +++ b/app/[orgId]/groups/mockData.ts @@ -6,6 +6,7 @@ export interface Group { about?: string; avatar?: string; users: string; // user emails seperated by newline + userEmails: string[]; usersCount?: number; } @@ -19,6 +20,11 @@ export const mockGroups: Group[] = [ avatar: "https://api.dicebear.com/7.x/initials/svg?seed=ET", users: "john.doe@example.com\nalice.smith@example.com\nbob.wilson@example.com", + userEmails: [ + "john.doe@example.com", + "alice.smith@example.com", + "bob.wilson@example.com", + ], }, { id: 2, @@ -28,6 +34,7 @@ export const mockGroups: Group[] = [ about: "Product design and UX team", avatar: "https://api.dicebear.com/7.x/initials/svg?seed=DT", users: "sarah.designer@example.com\nmike.ux@example.com", + userEmails: ["sarah.designer@example.com", "mike.ux@example.com"], }, { id: 3, @@ -38,6 +45,11 @@ export const mockGroups: Group[] = [ avatar: "https://api.dicebear.com/7.x/initials/svg?seed=MT", users: "emma.marketing@example.com\njames.growth@example.com\nlisa.social@example.com", + userEmails: [ + "emma.marketing@example.com", + "james.growth@example.com", + "lisa.social@example.com", + ], }, { id: 4, @@ -47,5 +59,6 @@ export const mockGroups: Group[] = [ about: "Product management and strategy", avatar: "https://api.dicebear.com/7.x/initials/svg?seed=PT", users: "david.pm@example.com\nanna.product@example.com", + userEmails: ["david.pm@example.com", "anna.product@example.com"], }, ]; diff --git a/app/[orgId]/groups/page.tsx b/app/[orgId]/groups/page.tsx index 0319170..f6958a7 100644 --- a/app/[orgId]/groups/page.tsx +++ b/app/[orgId]/groups/page.tsx @@ -43,9 +43,10 @@ const groupSchema = z.object({ }); const injectUsersCount = (groups: Group[]) => { + console.log("injecting users count", groups); return groups.map((group) => ({ ...group, - usersCount: group.users?.split(/\r?\n/).length ?? 0, + usersCount: group.userEmails.length ?? 0, })); }; @@ -80,7 +81,9 @@ export default function GroupsPage() { throw new Error(formatValidationErrors(errorData)); } const data = await response.json(); - setGroups(injectUsersCount(data)); + const updatedData = injectUsersCount(data); + console.log("updatedData", updatedData); + setGroups(updatedData); setShowMockAlert(false); } catch (error) { console.error("Error fetching groups:", error); @@ -137,10 +140,12 @@ export default function GroupsPage() { // Convert users from string to array of strings (splitting at newlines) if (typeof groupToSave.users === "string") { - groupToSave.users = groupToSave.users + groupToSave.emails = groupToSave.users .split("\n") .map((user) => user.trim()) .filter((user) => user.length > 0); // Remove empty lines + // groupToSave.users = null; + console.log("groupToSave.users", groupToSave.users); } console.log("groups", groupToSave); diff --git a/app/[orgId]/posts/[id]/page.tsx b/app/[orgId]/posts/[id]/page.tsx new file mode 100644 index 0000000..7158b48 --- /dev/null +++ b/app/[orgId]/posts/[id]/page.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { CalendarIcon, UserIcon, TagIcon } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +interface Post { + id: number; + title: string; + content: string; + tags: string; + slug: string; + createdAt: string; + updatedAt: string; + author: { + id: number; + name: string; + nameId: string; + }; +} + +// Default data as fallback +const defaultPostData: Post = { + id: 0, + title: "Loading...", + content: "Loading post content...", + tags: "", + slug: "", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + author: { + id: 0, + name: "Loading...", + nameId: "loading", + }, +}; + +export default function PostDetailsPage() { + const params = useParams(); + const orgId = params.orgId as string; + const postId = params.id as string; + + const [postData, setPostData] = useState(defaultPostData); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchPostData = async () => { + try { + setIsLoading(true); + const url = `/api/orgs/${orgId}/posts/${postId}`; + console.log(`Fetching post data from: ${url}`); + + const res = await fetch(url); + + if (!res.ok) { + console.error( + `Failed to fetch post data: ${res.status} ${res.statusText}`, + ); + throw new Error(`Failed to fetch post data: ${res.status}`); + } + + const data = await res.json(); + console.log("Successfully fetched post data:", data); + setPostData(data); + setError(null); + } catch (err) { + console.error("Error fetching post data:", err); + setError( + err instanceof Error ? err.message : "An unknown error occurred", + ); + } finally { + setIsLoading(false); + } + }; + + if (orgId && postId) { + fetchPostData(); + } + }, [orgId, postId]); + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }); + }; + + if (isLoading) { + return
Loading post details...
; + } + + if (error) { + return ( +
Error: {error}
+ ); + } + + return ( +
+ + +
+
+ + {postData.title} + + + Posted by {postData.author.name} + +
+
+
+ +
+ {postData.content.split("\n").map((paragraph, index) => ( +

+ {paragraph} +

+ ))} +
+ +
+
+ + + + +
+ {postData.tags && ( +
+ + + {postData.tags.split(",").map((tag, index) => ( + + #{tag.trim()} + + ))} + +
+ )} +
+ + Posted on {formatDate(postData.createdAt)} +
+ {postData.updatedAt !== postData.createdAt && ( +
+ + Updated on {formatDate(postData.updatedAt)} +
+ )} +
+
+
+
+ ); +} diff --git a/app/[orgId]/posts/page.tsx b/app/[orgId]/posts/page.tsx new file mode 100644 index 0000000..8f65bdc --- /dev/null +++ b/app/[orgId]/posts/page.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { GenericListing, ColumnDef } from "@/mint/generic-listing"; +import { GenericEditor, Field } from "@/mint/generic-editor"; +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { useToast } from "@/hooks/use-toast"; +import { formatValidationErrors } from "@/utils/error"; +import { MockAlert } from "@/components/mock-alert"; +import { z } from "zod"; + +interface Post { + id: number; + title: string; + content: string; + tags: string; + slug: string; + createdAt: string; + updatedAt: string; + author: { + id: number; + name: string; + nameId: string; + }; +} + +// Form data type for creating/updating posts +interface PostFormData { + title: string; + content: string; + tags?: string; +} + +const columns: ColumnDef[] = [ + { header: "Title", accessorKey: "title" }, + { header: "Author", accessorKey: "author" }, + { header: "Tags", accessorKey: "tags" }, + { header: "Created At", accessorKey: "createdAt" }, + { header: "Updated At", accessorKey: "updatedAt" }, +]; + +// Schema for creating/updating posts +const postFormSchema = z.object({ + title: z.string().min(2).max(200), + content: z.string().min(1), + tags: z.string().optional(), +}); + +// Schema for the complete post object +const postSchema = z.object({ + id: z.number(), + title: z.string().min(2).max(200), + content: z.string().min(1), + tags: z.string(), + slug: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + author: z.object({ + id: z.number(), + name: z.string(), + nameId: z.string(), + }), +}); + +const fields: Field[] = [ + { name: "title", label: "Title", type: "text" }, + { name: "content", label: "Content", type: "textarea" }, + { + name: "tags", + label: "Tags", + type: "text", + placeholder: "Comma-separated tags (e.g., programming,algorithms,beginner)", + }, +]; + +export default function PostsPage() { + const params = useParams(); + const orgId = params.orgId as string; + const { toast } = useToast(); + + const [posts, setPosts] = useState([]); + const [selectedPost, setSelectedPost] = useState(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [showMockAlert, setShowMockAlert] = useState(false); + + useEffect(() => { + const fetchPosts = async () => { + try { + const response = await fetch(`/api/orgs/${orgId}/posts`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(formatValidationErrors(errorData)); + } + const data = await response.json(); + setPosts(data.data); + setShowMockAlert(false); + } catch (error) { + console.error("Error fetching posts:", error); + toast({ + variant: "destructive", + title: "Error", + description: + error instanceof Error ? error.message : "Failed to fetch posts", + }); + setShowMockAlert(true); + } + }; + fetchPosts(); + }, [orgId, toast]); + + const deletePost = async (post: Post) => { + try { + const response = await fetch(`/api/orgs/${orgId}/posts/${post.slug}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(formatValidationErrors(errorData)); + } + + setPosts((prevPosts) => prevPosts.filter((p) => p.id !== post.id)); + toast({ + title: "Success", + description: "Post deleted successfully", + }); + return Promise.resolve(); + } catch (error) { + console.error("Error deleting post:", error); + toast({ + variant: "destructive", + title: "Error", + description: + error instanceof Error ? error.message : "Failed to delete post", + }); + return Promise.reject(error); + } + }; + + const savePost = async (formData: PostFormData) => { + try { + const url = selectedPost + ? `/api/orgs/${orgId}/posts/${selectedPost.slug}` + : `/api/orgs/${orgId}/posts`; + + const response = await fetch(url, { + method: selectedPost ? "PATCH" : "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(formatValidationErrors(errorData)); + } + + const savedPost = await response.json(); + + if (selectedPost) { + setPosts(posts.map((p) => (p.id === savedPost.id ? savedPost : p))); + toast({ + title: "Success", + description: "Post updated successfully", + }); + } else { + setPosts([...posts, savedPost]); + toast({ + title: "Success", + description: "Post created successfully", + }); + } + + setIsEditorOpen(false); + } catch (error) { + console.error("Error saving post:", error); + toast({ + variant: "destructive", + title: "Validation Error", + description: + error instanceof Error ? error.message : "Failed to save post", + }); + throw error; + } + }; + + return ( + <> + + { + setSelectedPost(null); + setIsEditorOpen(true); + }} + onEdit={(post) => { + setSelectedPost(post); + setIsEditorOpen(true); + }} + onDelete={deletePost} + rowClickAttr="slug" + /> + + + data={selectedPost} + isOpen={isEditorOpen} + onClose={() => setIsEditorOpen(false)} + onSave={savePost} + schema={postFormSchema} + fields={fields} + title="Post" + /> + + ); +} diff --git a/app/api/orgs/[orgId]/groups/route.ts b/app/api/orgs/[orgId]/groups/route.ts index e90c62e..819a177 100644 --- a/app/api/orgs/[orgId]/groups/route.ts +++ b/app/api/orgs/[orgId]/groups/route.ts @@ -32,23 +32,25 @@ export async function POST( console.log("request data", requestData); // Rename 'users' to 'emails' in the request data - const { users, ...restRequestData } = requestData; - const processedData = { - ...restRequestData, - emails: users, // Rename 'users' to 'emails' - }; - console.log("emails", processedData.emails); - console.log("emails", restRequestData); + // const { users, ...restRequestData } = requestData; + // console.log("*****users", users); + // const processedData = { + // ...restRequestData, + // emails: users, // Rename 'users' to 'emails' + // }; + // console.log("emails", processedData.emails); + // console.log("restRequestData", restRequestData); // Now validate with your schema - const { emails, ...rest } = createGroupSchema.parse(processedData); + const { emails, ...rest } = createGroupSchema.parse(requestData); - console.log("emails", emails); - console.log("rest", rest); + console.log("emails after validation parsed", emails); + console.log("rest after validation parsed", rest); const group = await groupsService.createGroup(orgId, rest); if (emails) { + console.log("BAK BAK emails", emails); const groupWithUserEmails = await groupService.updateGroupMembers( orgId, group.id, diff --git a/app/api/orgs/[orgId]/posts/[slug]/docs.ts b/app/api/orgs/[orgId]/posts/[slug]/docs.ts new file mode 100644 index 0000000..c36d2f9 --- /dev/null +++ b/app/api/orgs/[orgId]/posts/[slug]/docs.ts @@ -0,0 +1,181 @@ +import { documentRoute } from "@/lib/swagger/route-docs"; +import { NameIdSchema, updatePostSchema } from "@/lib/validations"; +import { z } from "zod"; + +// Document GET /orgs/{orgId}/posts/{slug} +documentRoute({ + method: "get", + path: "/orgs/{orgId}/posts/{slug}", + summary: "Get post by slug", + description: "Returns a specific post by its slug", + tags: ["Posts"], + request: { + params: z.object({ + orgId: NameIdSchema, + slug: z.string().describe("Unique slug for the post"), + }), + }, + responses: { + 200: { + description: "Post details", + schema: z.object({ + id: z.number(), + title: z.string(), + content: z.string(), + orgId: z.number(), + authorId: z.number(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + isPublished: z.boolean(), + slug: z.string(), + author: z.object({ + id: z.number(), + name: z.string(), + nameId: z.string(), + }), + tags: z.string(), + }), + }, + 404: { + description: "Organization or post not found", + schema: z.object({ + error: z.enum(["Organization not found", "Post not found"]), + }), + }, + 500: { + description: "Internal server error", + schema: z.object({ + error: z.literal("Failed to fetch post"), + }), + }, + }, +}); + +// Document PATCH /orgs/{orgId}/posts/{slug} +documentRoute({ + method: "patch", + path: "/orgs/{orgId}/posts/{slug}", + summary: "Update post", + description: "Updates an existing post", + tags: ["Posts"], + request: { + params: z.object({ + orgId: NameIdSchema, + slug: z.string().describe("Unique slug for the post"), + }), + body: updatePostSchema, + }, + responses: { + 200: { + description: "Post updated successfully", + schema: z.object({ + id: z.number(), + title: z.string(), + content: z.string(), + orgId: z.number(), + authorId: z.number(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + isPublished: z.boolean(), + slug: z.string(), + author: z.object({ + id: z.number(), + name: z.string(), + nameId: z.string(), + }), + tags: z.string(), + }), + }, + 400: { + description: "Validation error", + schema: z.object({ + error: z.array( + z.object({ + code: z.string(), + message: z.string(), + path: z.array(z.string()), + }), + ), + }), + }, + 401: { + description: "Unauthorized", + schema: z.object({ + error: z.literal("Unauthorized"), + }), + }, + 403: { + description: "Forbidden", + schema: z.object({ + error: z.literal("Unauthorized"), + }), + }, + 404: { + description: "Organization or post not found", + schema: z.object({ + error: z.enum(["Organization not found", "Post not found"]), + }), + }, + 500: { + description: "Internal server error", + schema: z.object({ + error: z.literal("Failed to update post"), + }), + }, + }, +}); + +// Document DELETE /orgs/{orgId}/posts/{slug} +documentRoute({ + method: "delete", + path: "/orgs/{orgId}/posts/{slug}", + summary: "Delete post", + description: "Deletes an existing post", + tags: ["Posts"], + request: { + params: z.object({ + orgId: NameIdSchema, + slug: z.string().describe("Unique slug for the post"), + }), + }, + responses: { + 200: { + description: "Post deleted successfully", + schema: z.object({ + id: z.number(), + title: z.string(), + content: z.string(), + orgId: z.number(), + authorId: z.number(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + isPublished: z.boolean(), + slug: z.string(), + }), + }, + 401: { + description: "Unauthorized", + schema: z.object({ + error: z.literal("Unauthorized"), + }), + }, + 403: { + description: "Forbidden", + schema: z.object({ + error: z.literal("Unauthorized"), + }), + }, + 404: { + description: "Organization or post not found", + schema: z.object({ + error: z.enum(["Organization not found", "Post not found"]), + }), + }, + 500: { + description: "Internal server error", + schema: z.object({ + error: z.literal("Failed to delete post"), + }), + }, + }, +}); diff --git a/app/api/orgs/[orgId]/posts/[slug]/route.ts b/app/api/orgs/[orgId]/posts/[slug]/route.ts new file mode 100644 index 0000000..0eedb62 --- /dev/null +++ b/app/api/orgs/[orgId]/posts/[slug]/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { NameIdSchema, updatePostSchema } from "@/lib/validations"; +import { getOrgIdFromNameId } from "@/app/api/service"; +import { deletePost, getPostBySlug, updatePost } from "../service"; +import { getCurrentSession } from "@/lib/server/session"; +// import { auth } from "@/lib/auth"; + +export async function GET( + _request: NextRequest, + { params }: { params: { orgId: string; slug: string } }, +) { + try { + const orgId = await getOrgIdFromNameId(NameIdSchema.parse(params.orgId)); + const post = await getPostBySlug(orgId, params.slug); + return NextResponse.json(post); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors }, { status: 400 }); + } + if (error instanceof Error) { + if (error.message === "Organization not found") { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + if (error.message === "Post not found") { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + } + return NextResponse.json( + { error: "Failed to fetch post" }, + { status: 500 }, + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: { orgId: string; slug: string } }, +) { + try { + const { user } = await getCurrentSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const orgId = await getOrgIdFromNameId(NameIdSchema.parse(params.orgId)); + const data = updatePostSchema.parse(await request.json()); + + const post = await updatePost(orgId, params.slug, data); + return NextResponse.json(post); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors }, { status: 400 }); + } + if (error instanceof Error) { + if (error.message === "Organization not found") { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + if (error.message === "Post not found") { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + if (error.message === "Unauthorized") { + return NextResponse.json({ error: error.message }, { status: 403 }); + } + } + return NextResponse.json( + { error: "Failed to update post" }, + { status: 500 }, + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: { orgId: string; slug: string } }, +) { + try { + const { user } = await getCurrentSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const orgId = await getOrgIdFromNameId(NameIdSchema.parse(params.orgId)); + const post = await deletePost(orgId, params.slug); + return NextResponse.json(post); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors }, { status: 400 }); + } + if (error instanceof Error) { + if (error.message === "Organization not found") { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + if (error.message === "Post not found") { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + if (error.message === "Unauthorized") { + return NextResponse.json({ error: error.message }, { status: 403 }); + } + } + return NextResponse.json( + { error: "Failed to delete post" }, + { status: 500 }, + ); + } +} diff --git a/app/api/orgs/[orgId]/posts/docs.ts b/app/api/orgs/[orgId]/posts/docs.ts new file mode 100644 index 0000000..5d163c1 --- /dev/null +++ b/app/api/orgs/[orgId]/posts/docs.ts @@ -0,0 +1,129 @@ +import { documentRoute } from "@/lib/swagger/route-docs"; +import { + NameIdSchema, + createPostSchema, + updatePostSchema, +} from "@/lib/validations"; +import { z } from "zod"; + +// Document GET /orgs/{orgId}/posts +documentRoute({ + method: "get", + path: "/orgs/{orgId}/posts", + summary: "List organization posts", + description: "Returns all posts for an organization", + tags: ["Posts"], + request: { + params: z.object({ + orgId: NameIdSchema, + }), + query: z.object({ + limit: z.string().optional().describe("Number of posts to return"), + offset: z.string().optional().describe("Offset for pagination"), + search: z.string().optional().describe("Search term"), + }), + }, + responses: { + 200: { + description: "List of posts", + schema: z.object({ + data: z.array( + z.object({ + id: z.number(), + title: z.string(), + content: z.string(), + orgId: z.number(), + authorId: z.number(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + isPublished: z.boolean(), + slug: z.string(), + author: z.object({ + id: z.number(), + name: z.string(), + nameId: z.string(), + }), + tags: z.string(), + }), + ), + total: z.number(), + limit: z.number(), + offset: z.number(), + }), + }, + 404: { + description: "Organization not found", + schema: z.object({ + error: z.literal("Organization not found"), + }), + }, + 500: { + description: "Internal server error", + schema: z.object({ + error: z.literal("Failed to fetch posts"), + }), + }, + }, +}); + +// Document POST /orgs/{orgId}/posts +documentRoute({ + method: "post", + path: "/orgs/{orgId}/posts", + summary: "Create post", + description: "Creates a new post in the organization", + tags: ["Posts"], + request: { + params: z.object({ + orgId: NameIdSchema, + }), + body: createPostSchema, + }, + responses: { + 201: { + description: "Post created successfully", + schema: z.object({ + id: z.number(), + title: z.string(), + content: z.string(), + orgId: z.number(), + authorId: z.number(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + isPublished: z.boolean(), + slug: z.string(), + tags: z.string(), + }), + }, + 400: { + description: "Validation error", + schema: z.object({ + error: z.array( + z.object({ + code: z.string(), + message: z.string(), + path: z.array(z.string()), + }), + ), + }), + }, + 401: { + description: "Unauthorized", + schema: z.object({ + error: z.literal("Unauthorized"), + }), + }, + 404: { + description: "Organization not found", + schema: z.object({ + error: z.literal("Organization not found"), + }), + }, + 500: { + description: "Internal server error", + schema: z.object({ + error: z.literal("Failed to create post"), + }), + }, + }, +}); diff --git a/app/api/orgs/[orgId]/posts/route.ts b/app/api/orgs/[orgId]/posts/route.ts new file mode 100644 index 0000000..4b2290c --- /dev/null +++ b/app/api/orgs/[orgId]/posts/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { + createPost, + getOrgPosts, + getPostBySlug, + updatePost, + deletePost, +} from "./service"; +import { + createPostSchema, + updatePostSchema, + NameIdSchema, +} from "@/lib/validations"; +import { getOrgIdFromNameId } from "@/app/api/service"; +import { getCurrentSession } from "@/lib/server/session"; +// import { auth } from "@/lib/auth"; + +export async function POST( + request: NextRequest, + { params }: { params: { orgId: string } }, +) { + try { + const { user } = await getCurrentSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const orgId = await getOrgIdFromNameId(NameIdSchema.parse(params.orgId)); + const data = createPostSchema.parse(await request.json()); + + const post = await createPost(orgId, user.id, data); + return NextResponse.json(post, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors }, { status: 400 }); + } + if (error instanceof Error) { + if (error.message === "Organization not found") { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + if (error.message === "Unauthorized") { + return NextResponse.json({ error: error.message }, { status: 403 }); + } + } + console.error(error); + return NextResponse.json( + { error: "Failed to create post" }, + { status: 500 }, + ); + } +} + +export async function GET( + req: NextRequest, + { params }: { params: { orgId: string } }, +) { + try { + const orgId = await getOrgIdFromNameId(NameIdSchema.parse(params.orgId)); + const { searchParams } = req.nextUrl; + const limit = Math.min(Number(searchParams.get("limit") || 10), 100); + const offset = Math.max(Number(searchParams.get("offset") || 0), 0); + const search = searchParams.get("search") || undefined; + + const result = await getOrgPosts(orgId, limit, offset, search); + return NextResponse.json(result); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors }, { status: 400 }); + } + if (error instanceof Error && error.message === "Organization not found") { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + console.error(error); + return NextResponse.json( + { error: "Failed to fetch posts" }, + { status: 500 }, + ); + } +} diff --git a/app/api/orgs/[orgId]/posts/service.ts b/app/api/orgs/[orgId]/posts/service.ts new file mode 100644 index 0000000..065b9e6 --- /dev/null +++ b/app/api/orgs/[orgId]/posts/service.ts @@ -0,0 +1,293 @@ +import { z } from "zod"; +import { db } from "@/db/drizzle"; +import { posts, postTags, users } from "@/db/schema"; +import { createPostSchema, updatePostSchema } from "@/lib/validations"; +import { and, eq, desc, count, ilike, asc } from "drizzle-orm"; +import { CACHE_TTL } from "@/db/redis"; +import { withDataCache } from "@/lib/cache/utils"; + +export async function createPost( + orgId: number, + authorId: number, + data: z.infer, +) { + return await db.transaction(async (tx) => { + // Extract tags from data if present + const tagsList = data.tags + ? data.tags + .split(",") + .map((t) => t.trim()) + .filter((t) => t) + : []; + + // Remove tags from data before inserting into posts table + const { tags, ...postData } = data; + + // Create a unique slug + const slug = await generateUniqueSlug(orgId, data.title); + + // Insert the post + const [post] = await tx + .insert(posts) + .values({ + ...postData, + slug, + orgId, + authorId, + }) + .returning(); + + // If there are tags, add them to the post + if (tagsList.length > 0) { + const tagEntries = tagsList.map((tag) => ({ + postId: post.id, + tag, + })); + + await tx.insert(postTags).values(tagEntries); + } + + return { + ...post, + tags: tagsList.join(","), + }; + }); +} + +// Helper function to generate a unique slug from a title +async function generateUniqueSlug(orgId: number, title: string) { + const baseSlug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + + let slug = baseSlug; + let counter = 0; + + // Check if the slug exists + let existing = await db.query.posts.findFirst({ + where: and(eq(posts.orgId, orgId), eq(posts.slug, slug)), + }); + + // If it exists, append a number until we find a unique slug + while (existing) { + counter++; + slug = `${baseSlug}-${counter}`; + existing = await db.query.posts.findFirst({ + where: and(eq(posts.orgId, orgId), eq(posts.slug, slug)), + }); + } + + return slug; +} + +export async function getOrgPosts( + orgId: number, + limit: number, + offset: number, + search?: string, +) { + const query = and(eq(posts.orgId, orgId)); + + // If search is provided, add a filter for title or content + const searchQuery = search + ? and(query, ilike(posts.title, `%${search}%`)) + : query; + + // Get posts + const postsData = await db + .select({ + post: posts, + author: { + id: users.id, + name: users.name, + nameId: users.nameId, + }, + }) + .from(posts) + .innerJoin(users, eq(posts.authorId, users.id)) + .where(searchQuery) + .orderBy(desc(posts.createdAt)) + .limit(limit) + .offset(offset); + + // Fetch tags for each post + const postsWithTags = await Promise.all( + postsData.map(async ({ post, author }) => { + const tagsData = await db + .select({ tag: postTags.tag }) + .from(postTags) + .where(eq(postTags.postId, post.id)) + .orderBy(asc(postTags.tag)); + + const tags = tagsData.map((t) => t.tag).join(","); + + return { + ...post, + author, + tags, + }; + }), + ); + + // Get total count + const [{ value: total }] = await db + .select({ value: count() }) + .from(posts) + .where(searchQuery); + + return { + data: postsWithTags, + total, + limit, + offset, + }; +} + +export async function getPostBySlug(orgId: number, slug: string) { + return withDataCache( + `post:${orgId}:${slug}`, + async () => { + // Get the post with author + const result = await db + .select({ + post: posts, + author: { + id: users.id, + name: users.name, + nameId: users.nameId, + }, + }) + .from(posts) + .innerJoin(users, eq(posts.authorId, users.id)) + .where(and(eq(posts.orgId, orgId), eq(posts.slug, slug))) + .limit(1); + + if (result.length === 0) { + throw new Error("Post not found"); + } + + const { post, author } = result[0]; + + // Get tags for the post + const tagsData = await db + .select({ tag: postTags.tag }) + .from(postTags) + .where(eq(postTags.postId, post.id)) + .orderBy(asc(postTags.tag)); + + const tags = tagsData.map((t) => t.tag).join(","); + + return { + ...post, + author, + tags, + }; + }, + CACHE_TTL.MEDIUM, + ); +} + +export async function updatePost( + orgId: number, + slug: string, + data: z.infer, +) { + return await db.transaction(async (tx) => { + // Check if post exists and belongs to the org + const post = await tx.query.posts.findFirst({ + where: and(eq(posts.orgId, orgId), eq(posts.slug, slug)), + }); + + if (!post) { + throw new Error("Post not found"); + } + + // Extract tags from data if present + const tagsList = data.tags + ? data.tags + .split(",") + .map((t) => t.trim()) + .filter((t) => t) + : null; + + // Remove tags from data before updating posts table + const { tags, ...postData } = data; + + // If title is being updated, generate a new slug + let newSlug = slug; + if (data.title && data.title !== post.title) { + newSlug = await generateUniqueSlug(orgId, data.title); + } + + // Update the post + const [updatedPost] = await tx + .update(posts) + .set({ + ...postData, + slug: newSlug, + updatedAt: new Date(), + }) + .where(eq(posts.id, post.id)) + .returning(); + + // If tags field was provided, update the post tags + if (tagsList !== null) { + // Delete existing tag associations + await tx.delete(postTags).where(eq(postTags.postId, post.id)); + + // If there are tags to add + if (tagsList.length > 0) { + const tagEntries = tagsList.map((tag) => ({ + postId: post.id, + tag, + })); + + await tx.insert(postTags).values(tagEntries); + } + } + + // Get the author info + const author = await tx.query.users.findFirst({ + where: eq(users.id, post.authorId), + columns: { + id: true, + name: true, + nameId: true, + }, + }); + + // Get updated tags for the post + const tagsData = await tx + .select({ tag: postTags.tag }) + .from(postTags) + .where(eq(postTags.postId, post.id)) + .orderBy(asc(postTags.tag)); + + const updatedTags = tagsData.map((t) => t.tag).join(","); + + return { + ...updatedPost, + author, + tags: updatedTags, + }; + }); +} + +export async function deletePost(orgId: number, slug: string) { + return await db.transaction(async (tx) => { + // Check if post exists and belongs to the org + const post = await tx.query.posts.findFirst({ + where: and(eq(posts.orgId, orgId), eq(posts.slug, slug)), + }); + + if (!post) { + throw new Error("Post not found"); + } + + // Delete the post (this will cascade delete the tags due to foreign key constraints) + await tx.delete(posts).where(eq(posts.id, post.id)); + + return post; + }); +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index a66556c..5596002 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -18,6 +18,7 @@ import { Monitor, Contact, LucideIcon, + SignpostBig, } from "lucide-react"; import { Check } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -171,9 +172,18 @@ const getNavItems = (role?: string): SidebarItem[] => { url: "groups", icon: Contact, allowedRoles: ["owner"], - disabled: true, - comingSoon: true, - hidden: true, + disabled: false, + comingSoon: false, + hidden: false, + }, + { + title: "Posts", + url: "posts", + icon: SignpostBig, + allowedRoles: ["owner"], + disabled: false, + comingSoon: false, + hidden: false, }, { title: "Contests", diff --git a/db/schema.ts b/db/schema.ts index 70b605e..b47a876 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -261,6 +261,53 @@ export const problemSubmissions = pgTable( }, ); +export const posts = pgTable( + "posts", + { + id: serial("id").primaryKey(), + + title: text("title").notNull(), + content: text("content").notNull(), // Use Markdown for content + + orgId: integer("org_id") + .notNull() + .references(() => orgs.id, { onDelete: "cascade" }), + authorId: integer("author_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + + isPublished: boolean("is_published").default(true).notNull(), + slug: text("slug").notNull(), + }, + (table) => { + return { + orgIdIdx: index("post_org_id_idx").on(table.orgId), + authorIdIdx: index("post_author_id_idx").on(table.authorId), + createdAtIdx: index("post_created_at_idx").on(table.createdAt), + slugIdx: uniqueIndex("post_slug_idx").on(table.orgId, table.slug), + }; + }, +); + +export const postTags = pgTable( + "post_tags", + { + postId: integer("post_id") + .notNull() + .references(() => posts.id, { onDelete: "cascade" }), + tag: text("tag").notNull(), + }, + (table) => { + return { + pk: primaryKey({ columns: [table.postId, table.tag] }), + postIdIdx: index("post_tags_post_id_idx").on(table.postId), + }; + }, +); + export const sessionTable = pgTable("session", { id: text("id").primaryKey(), userId: integer("user_id") @@ -300,4 +347,10 @@ export type InsertContestProblem = typeof contestProblems.$inferInsert; export type SelectProblemSubmission = typeof problemSubmissions.$inferSelect; export type InsertProblemSubmission = typeof problemSubmissions.$inferInsert; +export type SelectPost = typeof posts.$inferSelect; +export type InsertPost = typeof posts.$inferInsert; + +export type SelectPostTag = typeof postTags.$inferSelect; +export type InsertPostTag = typeof postTags.$inferInsert; + export type Session = typeof sessionTable.$inferSelect; diff --git a/lib/validations.ts b/lib/validations.ts index 5c43524..85106df 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -377,3 +377,28 @@ export const testCaseSchema = z title: "TestCase", description: "Test case schema", }); + +// Post Schemas +export const createPostSchema = z + .object({ + title: z.string().min(2).max(200).openapi({ + example: "Getting Started with Competitive Programming", + description: "Post's title", + }), + content: z.string().min(1).openapi({ + example: "Competitive programming is a mind sport...", + description: "Post's content in markdown format", + }), + tags: z.string().optional().openapi({ + example: "competitive-programming,algorithms,beginner", + description: "Comma-separated list of tags", + }), + }) + .openapi({ + title: "CreatePost", + description: "Schema for creating a new post", + }); + +export const updatePostSchema = createPostSchema + .partial() + .openapi({ description: "Schema for updating an existing post" }); diff --git a/migrations/0003_uneven_ricochet.sql b/migrations/0003_uneven_ricochet.sql new file mode 100644 index 0000000..b4b0cf4 --- /dev/null +++ b/migrations/0003_uneven_ricochet.sql @@ -0,0 +1,26 @@ +CREATE TABLE "post_tags" ( + "post_id" integer NOT NULL, + "tag" text NOT NULL, + CONSTRAINT "post_tags_post_id_tag_pk" PRIMARY KEY("post_id","tag") +); +--> statement-breakpoint +CREATE TABLE "posts" ( + "id" serial PRIMARY KEY NOT NULL, + "title" text NOT NULL, + "content" text NOT NULL, + "org_id" integer NOT NULL, + "author_id" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "is_published" boolean DEFAULT true NOT NULL, + "slug" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "post_tags" ADD CONSTRAINT "post_tags_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_org_id_orgs_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."orgs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "post_tags_post_id_idx" ON "post_tags" USING btree ("post_id");--> statement-breakpoint +CREATE INDEX "post_org_id_idx" ON "posts" USING btree ("org_id");--> statement-breakpoint +CREATE INDEX "post_author_id_idx" ON "posts" USING btree ("author_id");--> statement-breakpoint +CREATE INDEX "post_created_at_idx" ON "posts" USING btree ("created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "post_slug_idx" ON "posts" USING btree ("org_id","slug"); \ No newline at end of file diff --git a/migrations/meta/0003_snapshot.json b/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..d66c808 --- /dev/null +++ b/migrations/meta/0003_snapshot.json @@ -0,0 +1,1364 @@ +{ + "id": "fc5c34c7-2032-4694-939c-2355162f5664", + "prevId": "af880a00-8dac-4e9e-ad0e-283a25a8b3a6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.contest_participants": { + "name": "contest_participants", + "schema": "", + "columns": { + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contest_participant_contest_idx": { + "name": "contest_participant_contest_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contest_participant_user_idx": { + "name": "contest_participant_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contest_participants_contest_id_contests_id_fk": { + "name": "contest_participants_contest_id_contests_id_fk", + "tableFrom": "contest_participants", + "tableTo": "contests", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contest_participants_user_id_users_id_fk": { + "name": "contest_participants_user_id_users_id_fk", + "tableFrom": "contest_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "contest_participants_contest_id_user_id_pk": { + "name": "contest_participants_contest_id_user_id_pk", + "columns": [ + "contest_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contest_problems": { + "name": "contest_problems", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "contest_problem_unique_constraint": { + "name": "contest_problem_unique_constraint", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contest_idx": { + "name": "contest_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_idx": { + "name": "order_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contest_problems_contest_id_contests_id_fk": { + "name": "contest_problems_contest_id_contests_id_fk", + "tableFrom": "contest_problems", + "tableTo": "contests", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contest_problems_problem_id_problems_id_fk": { + "name": "contest_problems_problem_id_problems_id_fk", + "tableFrom": "contest_problems", + "tableTo": "problems", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contests": { + "name": "contests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name_id": { + "name": "name_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizer_id": { + "name": "organizer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organizerKind": { + "name": "organizerKind", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'org'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "registration_start_time": { + "name": "registration_start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "registration_end_time": { + "name": "registration_end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "allow_list": { + "name": "allow_list", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "disallow_list": { + "name": "disallow_list", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "organizer_idx": { + "name": "organizer_idx", + "columns": [ + { + "expression": "organizer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "start_time_idx": { + "name": "start_time_idx", + "columns": [ + { + "expression": "start_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "end_time_idx": { + "name": "end_time_idx", + "columns": [ + { + "expression": "end_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_memberships": { + "name": "group_memberships", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_id_idx": { + "name": "group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "group_user_id_idx": { + "name": "group_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_memberships_group_id_groups_id_fk": { + "name": "group_memberships_group_id_groups_id_fk", + "tableFrom": "group_memberships", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_memberships_user_id_users_id_fk": { + "name": "group_memberships_user_id_users_id_fk", + "tableFrom": "group_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_memberships_group_id_user_id_pk": { + "name": "group_memberships_group_id_user_id_pk", + "columns": [ + "group_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groups": { + "name": "groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name_id": { + "name": "name_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "about": { + "name": "about", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groups_org_id_orgs_id_fk": { + "name": "groups_org_id_orgs_id_fk", + "tableFrom": "groups", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "groups_name_id_unique": { + "name": "groups_name_id_unique", + "nullsNotDistinct": false, + "columns": [ + "name_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_id_idx": { + "name": "org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_org_id_orgs_id_fk": { + "name": "memberships_org_id_orgs_id_fk", + "tableFrom": "memberships", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_org_id_user_id_pk": { + "name": "memberships_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orgs": { + "name": "orgs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name_id": { + "name": "name_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "about": { + "name": "about", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orgs_name_id_unique": { + "name": "orgs_name_id_unique", + "nullsNotDistinct": false, + "columns": [ + "name_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tags": { + "name": "post_tags", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "post_tags_post_id_idx": { + "name": "post_tags_post_id_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_tags_post_id_posts_id_fk": { + "name": "post_tags_post_id_posts_id_fk", + "tableFrom": "post_tags", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tags_post_id_tag_pk": { + "name": "post_tags_post_id_tag_pk", + "columns": [ + "post_id", + "tag" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "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()" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "post_org_id_idx": { + "name": "post_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_author_id_idx": { + "name": "post_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_created_at_idx": { + "name": "post_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_slug_idx": { + "name": "post_slug_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_org_id_orgs_id_fk": { + "name": "posts_org_id_orgs_id_fk", + "tableFrom": "posts", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_author_id_users_id_fk": { + "name": "posts_author_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem_submissions": { + "name": "problem_submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "contest_problem_id": { + "name": "contest_problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_time": { + "name": "execution_time", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "memory_usage": { + "name": "memory_usage", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_idx": { + "name": "user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contest_problem_idx": { + "name": "contest_problem_idx", + "columns": [ + { + "expression": "contest_problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_at_idx": { + "name": "submitted_at_idx", + "columns": [ + { + "expression": "submitted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problem_submissions_user_id_users_id_fk": { + "name": "problem_submissions_user_id_users_id_fk", + "tableFrom": "problem_submissions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problem_submissions_contest_problem_id_contest_problems_id_fk": { + "name": "problem_submissions_contest_problem_id_contest_problems_id_fk", + "tableFrom": "problem_submissions", + "tableTo": "contest_problems", + "columnsFrom": [ + "contest_problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problems": { + "name": "problems", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_languages": { + "name": "allowed_languages", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "problems_org_id_orgs_id_fk": { + "name": "problems_org_id_orgs_id_fk", + "tableFrom": "problems", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "problems_code_unique": { + "name": "problems_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_users_id_fk": { + "name": "session_user_id_users_id_fk", + "tableFrom": "session", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.test_cases": { + "name": "test_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'test'" + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problem_id_idx": { + "name": "problem_id_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "test_cases_problem_id_problems_id_fk": { + "name": "test_cases_problem_id_problems_id_fk", + "tableFrom": "test_cases", + "tableTo": "problems", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name_id": { + "name": "name_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hashed_password": { + "name": "hashed_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_superuser": { + "name": "is_superuser", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "about": { + "name": "about", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_name_id_unique": { + "name": "users_name_id_unique", + "nullsNotDistinct": false, + "columns": [ + "name_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index d42936f..af73eef 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1736482279838, "tag": "0002_omniscient_domino", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1746397809640, + "tag": "0003_uneven_ricochet", + "breakpoints": true } ] } \ No newline at end of file