From d795ff9599e08e378f839644005a9bf4711ced6c Mon Sep 17 00:00:00 2001 From: blaze2004 <72434294+blaze2004@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:30:54 +0000 Subject: [PATCH] Implement group management and direct API upload for Trackit - Completed Group CRUD (Delete functionality with report migration) - Implemented Group Invitation system via email - Added Move Report functionality between groups - Fleshed out Group Stream, Schedule, and People tabs - Implemented direct API upload from extension to cloud with fallback to localStorage - Added authenticated API endpoint for report uploads - Renamed 'Members' to 'People' for better UX consistency - Fixed linting issues and ensured code integrity across monorepo Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- .../ui/scripts/attendance-tracker.ts | 74 ++++++- .../g/[groupId]/(views)/layout.tsx | 2 +- .../(views)/members/invite-modal.tsx | 112 ++++++++++ .../g/[groupId]/(views)/members/page.tsx | 16 +- .../(protected)/g/[groupId]/(views)/page.tsx | 83 +++++++- .../g/[groupId]/(views)/schedule/page.tsx | 92 +++++++- .../schedule/schedule-meeting-modal.tsx | 196 ++++++++++++++++++ .../(views)/settings/delete-group.tsx | 24 ++- .../(protected)/g/[groupId]/r/[slug]/page.tsx | 10 + apps/web/src/app/api/reports/upload/route.ts | 143 +++++++++++++ .../components/dashboard/reports/settings.tsx | 132 ++++++++---- apps/web/src/emails/group-invite.tsx | 50 +++++ apps/web/src/lib/api/groups/index.ts | 66 ++++++ apps/web/src/lib/api/groups/settings.ts | 93 +++++++++ apps/web/src/lib/api/meetings/index.ts | 114 ++++++++++ apps/web/src/lib/api/reports/index.ts | 79 +++++++ 16 files changed, 1232 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/members/invite-modal.tsx create mode 100644 apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/schedule/schedule-meeting-modal.tsx create mode 100644 apps/web/src/app/api/reports/upload/route.ts create mode 100644 apps/web/src/emails/group-invite.tsx create mode 100644 apps/web/src/lib/api/meetings/index.ts diff --git a/apps/extension/entrypoints/ui/scripts/attendance-tracker.ts b/apps/extension/entrypoints/ui/scripts/attendance-tracker.ts index 4a54187..33b13b6 100644 --- a/apps/extension/entrypoints/ui/scripts/attendance-tracker.ts +++ b/apps/extension/entrypoints/ui/scripts/attendance-tracker.ts @@ -1,6 +1,6 @@ import { MeetingState, Participant } from "@/types"; import { BASE_URL } from "@/utils/constants"; -import { isSameDay } from "date-fns"; +import { isSameDay, format } from "date-fns"; const SAVE_INTERVAL = 60000; let lastSaveTime = new Date().getTime(); @@ -126,16 +126,78 @@ const saveAttendanceData = async (isFinal = false) => { participant.leaveTime = participant.lastAttendedTimeStamp; }); + const dataString = JSON.stringify(window.trackit.meetData, replacer); + await browser.storage.local.set({ - [window.trackit.meetData.uuid]: JSON.stringify( - window.trackit.meetData, - replacer - ), + [window.trackit.meetData.uuid]: dataString, }); if (isFinal) { - window.open(`${BASE_URL}/save-report`); + const uploaded = await uploadAttendanceData(window.trackit.meetData); + if (uploaded && !uploaded.redirected) { + // If uploaded successfully, we can remove it from local storage + await browser.storage.local.remove(window.trackit.meetData.uuid); + // Redirect to the report page + window.open(`${BASE_URL}/g/${uploaded.groupId}/r/${uploaded.slug}`); + } else if (uploaded?.redirected) { + // Do nothing, user was redirected to login + } else { + // Fallback to the old method + alert("Direct upload failed. Falling back to local storage method."); + window.open(`${BASE_URL}/save-report`); + } + } +}; + +const uploadAttendanceData = async (meetData: MeetingState) => { + try { + const { authToken } = await browser.storage.local.get("authToken"); + if (!authToken) { + console.log("No auth token found, redirecting to login."); + const confirmLogin = window.confirm("You are not signed in. Would you like to sign in to save your attendance report directly to the cloud?"); + if (confirmLogin) { + window.open(`${BASE_URL}/api/auth/signin`); + return { redirected: true }; // Special return value to indicate redirection + } + return null; + } + + const participants = Array.from(meetData.participants.values()).map((p) => ({ + name: p.name, + joinTime: p.joinTime.toISOString(), + leaveTime: p.leaveTime?.toISOString() || p.lastAttendedTimeStamp.toISOString(), + avatarUrl: p.avatarUrl, + attendedDuration: p.attendedDuration, + })); + + const payload = { + meetCode: meetData.meetCode, + date: format(meetData.date, "dd/MM/yyyy"), + startTime: format(meetData.startTime, "HH:mm:ss"), + stopTime: format(meetData.endTime, "HH:mm:ss"), + participants, + groupId: meetData.groupId, + }; + + const response = await fetch(`${BASE_URL}/api/reports/upload`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + const result = await response.json(); + return result; + } else if (response.status === 401) { + console.log("Unauthorized, token might be expired."); + } + } catch (error) { + console.error("Error uploading attendance data:", error); } + return null; }; export const tracker = () => { diff --git a/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/layout.tsx b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/layout.tsx index 6dbc65e..0ba4278 100644 --- a/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/layout.tsx +++ b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/layout.tsx @@ -50,7 +50,7 @@ export default async function GroupLayout({ href: `/g/${groupId}/schedule`, }, { - title: "Members", + title: "People", href: `/g/${groupId}/members`, }, ]; diff --git a/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/members/invite-modal.tsx b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/members/invite-modal.tsx new file mode 100644 index 0000000..401e68d --- /dev/null +++ b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/members/invite-modal.tsx @@ -0,0 +1,112 @@ +"use client"; +import { inviteUserToGroup } from "@/lib/api/groups"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@repo/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@repo/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@repo/ui/form"; +import { LoadingCircle } from "@repo/ui/icons"; +import { Input } from "@repo/ui/input"; +import { toast } from "@repo/ui/sonner"; +import { UserPlusIcon } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const FormSchema = z.object({ + email: z.string().email({ message: "Please enter a valid email address." }), +}); + +export default function InviteMemberModal({ groupId }: { groupId: string }) { + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + email: "", + }, + }); + + async function onSubmit(data: z.infer) { + setIsLoading(true); + const res = await inviteUserToGroup({ + groupId, + email: data.email, + }); + if (res.success) { + toast.success(res.message); + setIsOpen(false); + form.reset(); + } else { + toast.error(res.message); + } + setIsLoading(false); + } + + return ( + + + + + + + Invite Member + + Send an invitation email with the join code to add someone to this + group. + + +
+ + ( + + Email Address + + + + + + )} + /> + + + +
+
+ ); +} diff --git a/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/members/page.tsx b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/members/page.tsx index bfa9a69..2c1d765 100644 --- a/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/members/page.tsx +++ b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/members/page.tsx @@ -11,6 +11,7 @@ import { import { dbClient } from "@/lib/db/db_client"; import { getServerSession } from "next-auth"; import GroupMemberActions from "./member-actions"; +import InviteMemberModal from "./invite-modal"; export default async function GroupInterface({ params: { groupId }, @@ -38,11 +39,16 @@ export default async function GroupInterface({ return ( - Members - + People +
+ + {(current_user.role === "ADMIN" || current_user.role === "OWNER") && ( + + )} +
diff --git a/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/page.tsx b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/page.tsx index e1326d9..0e365d7 100644 --- a/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/page.tsx +++ b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/page.tsx @@ -1,7 +1,84 @@ -const GroupStreamPage = () => { +import { dbClient } from "@/lib/db/db_client"; +import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/card"; +import { TypographyH3, TypographyP } from "@repo/ui/typography"; +import { FileBarChart2Icon, UsersIcon, CalendarIcon } from "lucide-react"; + +const GroupStreamPage = async ({ + params: { groupId }, +}: { + params: { groupId: string }; +}) => { + const stats = await dbClient.transaction().execute(async (trx) => { + const membersCount = await trx + .selectFrom("GroupMember") + .select(({ fn }) => fn.count("id").as("count")) + .where("groupId", "=", groupId) + .executeTakeFirst(); + + const reportsCount = await trx + .selectFrom("AttendanceReport") + .innerJoin("Meeting", "AttendanceReport.meetingId", "Meeting.id") + .select(({ fn }) => fn.count("AttendanceReport.id").as("count")) + .where("Meeting.groupId", "=", groupId) + .executeTakeFirst(); + + const upcomingMeetingsCount = await trx + .selectFrom("Meeting") + .select(({ fn }) => fn.count("id").as("count")) + .where("groupId", "=", groupId) + .where("date", ">=", new Date()) + .executeTakeFirst(); + + return { + members: membersCount?.count || 0, + reports: reportsCount?.count || 0, + meetings: upcomingMeetingsCount?.count || 0, + }; + }); + return ( -
-

GroupStreamPage

+
+
+ + + Total Members + + + +
{stats.members}
+
+
+ + + Attendance Reports + + + +
{stats.reports}
+
+
+ + + Upcoming Meetings + + + +
{stats.meetings}
+
+
+
+ + + + Welcome to your Group Stream + + + + This is your central hub for tracking attendance, scheduling meetings, and managing group members. + Use the tabs above to navigate through different features. + + +
); }; diff --git a/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/schedule/page.tsx b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/schedule/page.tsx index 53a91ef..d42b7e1 100644 --- a/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/schedule/page.tsx +++ b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/schedule/page.tsx @@ -1,7 +1,93 @@ -export default function GroupSchedulePage() { +import { dbClient } from "@/lib/db/db_client"; +import { getServerSession } from "next-auth"; +import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/card"; +import { format } from "date-fns"; +import { CalendarIcon, MapPinIcon, VideoIcon } from "lucide-react"; +import ScheduleMeetingModal from "./schedule-meeting-modal"; +import { Button } from "@repo/ui/button"; +import Link from "next/link"; + +export default async function GroupSchedulePage({ + params: { groupId }, +}: { + params: { groupId: string }; +}) { + const session = await getServerSession(); + const email = session?.user?.email; + + const meetings = await dbClient + .selectFrom("Meeting") + .selectAll() + .where("groupId", "=", groupId) + .where("date", ">=", new Date()) + .orderBy("date", "asc") + .execute(); + + const userRole = await dbClient + .selectFrom("GroupMember") + .innerJoin("User", "GroupMember.userId", "User.id") + .select("GroupMember.role") + .where("User.email", "=", email) + .where("GroupMember.groupId", "=", groupId) + .executeTakeFirst(); + + const isOwnerOrAdmin = userRole?.role === "OWNER" || userRole?.role === "ADMIN"; + return ( -
-

GroupSchedulePage

+
+
+

Upcoming Meetings

+ {isOwnerOrAdmin && } +
+ +
+ {meetings.length === 0 ? ( + + + No upcoming meetings scheduled. + + + ) : ( + meetings.map((meeting) => ( + + + {meeting.name} +
+ {meeting.isOnline ? ( + + ) : ( + + )} +
+
+ +
+
+ + {format(new Date(meeting.date), "PPP")} at{" "} + {meeting.startTime ? format(new Date(meeting.startTime), "p") : "-"} - {" "} + {meeting.endTime ? format(new Date(meeting.endTime), "p") : "-"} +
+ {meeting.agenda && ( +

+ {typeof meeting.agenda === "string" ? meeting.agenda : JSON.stringify(meeting.agenda)} +

+ )} + {meeting.meetLink && ( +
+ +
+ )} +
+
+
+ )) + )} +
); } diff --git a/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/schedule/schedule-meeting-modal.tsx b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/schedule/schedule-meeting-modal.tsx new file mode 100644 index 0000000..70ce0f2 --- /dev/null +++ b/apps/web/src/app/(application)/(protected)/g/[groupId]/(views)/schedule/schedule-meeting-modal.tsx @@ -0,0 +1,196 @@ +"use client"; +import { createMeeting } from "@/lib/api/meetings"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@repo/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@repo/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@repo/ui/form"; +import { LoadingCircle } from "@repo/ui/icons"; +import { Input } from "@repo/ui/input"; +import { toast } from "@repo/ui/sonner"; +import { Textarea } from "@repo/ui/textarea"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useRouter } from "next13-progressbar"; + +const FormSchema = z.object({ + name: z.string().min(2, { message: "Meeting name must be atleast 2 characters." }), + date: z.string(), + startTime: z.string(), + endTime: z.string(), + agenda: z.string().optional(), + meetLink: z.string().url().optional().or(z.literal("")), +}); + +export default function ScheduleMeetingModal({ groupId }: { groupId: string }) { + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + name: "", + date: new Date().toISOString().split("T")[0], + startTime: "12:00", + endTime: "13:00", + agenda: "", + meetLink: "", + }, + }); + + async function onSubmit(data: z.infer) { + setIsLoading(true); + const res = await createMeeting({ + groupId, + name: data.name, + date: new Date(data.date), + startTime: data.startTime, + endTime: data.endTime, + agenda: data.agenda, + meetLink: data.meetLink || undefined, + }); + if (res.success) { + toast.success(res.message); + setIsOpen(false); + form.reset(); + router.refresh(); + } else { + toast.error(res.message); + } + setIsLoading(false); + } + + return ( + + + + + + + Schedule New Meeting + + Fill in the details to schedule a new meeting for this group. + + +
+ + ( + + Meeting Title + + + + + + )} + /> +
+ ( + + Date + + + + + + )} + /> + ( + + Start Time + + + + + + )} + /> +
+
+ ( + + End Time + + + + + + )} + /> + ( + + Meet Link (Optional) + + + + + + )} + /> +
+ ( + + Agenda + +