Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 163 additions & 5 deletions graphql/resolvers/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
import DataLoader from "dataloader";
import { createClient } from "../../src/lib/supabase/client";

const supabase = createClient();

// ── Lightweight Batch Loader Class ──

class SimpleDataLoader<K extends string, V> {
private batchFn: (keys: readonly K[]) => Promise<(V | null)[]>;
private cache = new Map<K, V | null>();

constructor(batchFn: (keys: readonly K[]) => Promise<(V | null)[]>) {
this.batchFn = batchFn;
}

async load(key: K): Promise<V | null> {
if (this.cache.has(key)) {
return this.cache.get(key) || null;
}
const results = await this.batchFn([key]);
const val = results[0] || null;
this.cache.set(key, val);
return val;
}
}
Comment thread
nayanraj864-cmyk marked this conversation as resolved.

// ── Interfaces ──

interface ProfileRecord {
Expand All @@ -26,10 +46,47 @@ interface CommentRecord {
deleted_at: string | null;
}

export interface EventRecord {
id: string;
club_id: string;
title: string;
description: string | null;
banner_url: string | null;
event_date: string | null;
start_date: string | null;
end_date: string | null;
location: string | null;
created_by: string | null;
created_at: string;
updated_at?: string | null;
is_private?: boolean | null;
}

// ── Cursor Encoding / Decoding Helpers ──

export function encodeCursor(record: { created_at: string; id: string }): string {
const str = `${record.created_at}::${record.id}`;
return typeof btoa === "function" ? btoa(str) : Buffer.from(str, "utf-8").toString("base64");
}

export function decodeCursor(cursor: string): { createdAt: string; id: string } | null {
try {
const str =
typeof atob === "function" ? atob(cursor) : Buffer.from(cursor, "base64").toString("utf-8");
const parts = str.split("::");
if (parts.length === 2 && parts[0] && parts[1]) {
return { createdAt: parts[0], id: parts[1] };
}
} catch {
return null;
}
return null;
}

// ── DataLoaders for batching nested relations (solving N+1) ──

// Batch fetch profiles by ID array
const profileLoader = new DataLoader<string, ProfileRecord | null>(async (userIds) => {
const profileLoader = new SimpleDataLoader<string, ProfileRecord>(async (userIds) => {
const { data, error } = await supabase
.from("profiles")
.select("*")
Expand All @@ -44,7 +101,7 @@ const profileLoader = new DataLoader<string, ProfileRecord | null>(async (userId
});

// Batch fetch clubs by ID array
const clubLoader = new DataLoader<string, ClubRecord | null>(async (clubIds) => {
const clubLoader = new SimpleDataLoader<string, ClubRecord>(async (clubIds) => {
const { data, error } = await supabase
.from("clubs")
.select("*")
Expand All @@ -57,7 +114,7 @@ const clubLoader = new DataLoader<string, ClubRecord | null>(async (clubIds) =>
});

// Batch fetch comments for a set of post IDs
const commentsByPostLoader = new DataLoader<string, CommentRecord[]>(async (postIds) => {
const commentsByPostLoader = new SimpleDataLoader<string, CommentRecord[]>(async (postIds) => {
const { data, error } = await supabase
.from("comments")
.select("*")
Expand All @@ -73,7 +130,7 @@ const commentsByPostLoader = new DataLoader<string, CommentRecord[]>(async (post
commentsGrouped.get(comment.post_id)?.push(comment);
});

return postIds.map((id) => commentsGrouped.get(id) || []);
return postIds.map((id) => commentsGrouped.get(id) || null);
});

// ── GraphQL Type Definitions ──
Expand Down Expand Up @@ -112,12 +169,51 @@ export const typeDefs = /* GraphQL */ `
comments: [Comment!]!
}

type Event {
id: ID!
club_id: ID!
title: String!
description: String
banner_url: String
event_date: String
start_date: String
end_date: String
location: String
created_by: ID
created_at: String
updated_at: String
is_private: Boolean
club: Club
organizer: Profile
}

type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

type EventEdge {
cursor: String!
node: Event!
}

type EventConnection {
edges: [EventEdge!]!
nodes: [Event!]!
pageInfo: PageInfo!
totalCount: Int!
}

type Query {
posts(limit: Int, offset: Int): [Post!]!
post(id: ID!): Post
clubs: [Club!]!
profiles(limit: Int, offset: Int, sortBy: String, sortOrder: String): [Profile!]!
totalProfiles: Int!
events(first: Int, after: String): EventConnection!
event(id: ID!): Event
}

type Mutation {
Expand Down Expand Up @@ -191,6 +287,59 @@ export const resolvers = {
if (error) throw error;
return count || 0;
},
events: async (_: unknown, { first = 10, after }: { first?: number; after?: string }) => {
const limit = Math.max(1, Math.min(first, 100));
let query = supabase.from("events").select("*", { count: "exact" });
Comment thread
nayanraj864-cmyk marked this conversation as resolved.

if (after) {
const decoded = decodeCursor(after);
if (decoded) {
// Robust keyset pagination: created_at < cursor.createdAt OR (created_at = cursor.createdAt AND id < cursor.id)
query = query.or(
`created_at.lt.${decoded.createdAt},and(created_at.eq.${decoded.createdAt},id.lt.${decoded.id})`,
);
}
}

Comment thread
nayanraj864-cmyk marked this conversation as resolved.
// Fetch limit + 1 items to accurately calculate hasNextPage
query = query
.order("created_at", { ascending: false })
.order("id", { ascending: false })
.limit(limit + 1);

const { data, count, error } = await query;
if (error) throw error;

const rawEvents: EventRecord[] = data || [];
const hasNextPage = rawEvents.length > limit;
const nodes = hasNextPage ? rawEvents.slice(0, limit) : rawEvents;

const edges = nodes.map((node) => ({
cursor: encodeCursor(node),
node,
}));

const startCursor = edges.length > 0 ? edges[0].cursor : null;
const endCursor = edges.length > 0 ? edges[edges.length - 1].cursor : null;

return {
edges,
nodes,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor,
endCursor,
},
totalCount: count ?? nodes.length,
};
},
event: async (_: unknown, { id }: { id: string }) => {
const { data, error } = await supabase.from("events").select("*").eq("id", id).single();

if (error) throw error;
return data;
},
Comment thread
nayanraj864-cmyk marked this conversation as resolved.
},

Mutation: {
Expand Down Expand Up @@ -223,4 +372,13 @@ export const resolvers = {
return parent.author_id ? profileLoader.load(parent.author_id) : null;
},
},

Event: {
club: (parent: { club_id: string }) => {
return parent.club_id ? clubLoader.load(parent.club_id) : null;
},
organizer: (parent: { created_by: string }) => {
return parent.created_by ? profileLoader.load(parent.created_by) : null;
},
},
};
16 changes: 8 additions & 8 deletions src/components/Feed/CommentSection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTypingIndicator } from "@/hooks/useTypingIndicator";
import { useRealtimeComments } from "@/hooks/useRealtimeComments";
import { useSupabaseSubscription } from "@/hooks/useSupabaseSubscription";
import { supabase } from "@/lib/supabase/client";

Expand Down Expand Up @@ -46,14 +47,13 @@ export const CommentSection: React.FC<CommentSectionProps> = ({
});
}, [postId]);

useSupabaseSubscription<Comment>({
table: "comments",
event: "INSERT",
filter: `post_id=eq.${postId}`,
channelName: `comments:post_id=eq.${postId}`,
onData: (payload) => {
if (payload.new && "id" in payload.new) {
setComments((prev) => [...prev, payload.new as Comment]);
useRealtimeComments({
postId,
enabled: !!postId,
onNewComment: (newComment) => {
setComments((prev) => [...prev, newComment]);
if (onNewComment) {
onNewComment(newComment);
}
},
});
Expand Down
Loading