diff --git a/.nvmrc b/.nvmrc index 9bdb657cd..385d7caac 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.16 \ No newline at end of file +v25.6.1 diff --git a/MIGRATION_STATUS.md b/MIGRATION_STATUS.md new file mode 100644 index 000000000..cdf81e878 --- /dev/null +++ b/MIGRATION_STATUS.md @@ -0,0 +1,147 @@ +# Utils Package Migration Status + +## Overview +This document tracks the progress of migrating utility functions from various locations into the centralized `@forge/utils` package. + +## What Has Been Done ✅ + +### 1. Created `@forge/utils` Package +- Created new package at `packages/utils/` +- Set up package.json with proper dependencies +- Configured TypeScript, ESLint, and build setup + +### 2. Migrated Functions to `@forge/utils` + +#### Discord Utilities (`packages/utils/src/discord.ts`) +- ✅ `api` - Discord REST API client +- ✅ `addRoleToMember` +- ✅ `removeRoleFromMember` +- ✅ `addMemberToServer` +- ✅ `handleDiscordOAuthCallback` +- ✅ `resolveDiscordUserId` +- ✅ `isDiscordAdmin` +- ✅ `isDiscordMember` +- ✅ `isDiscordVIP` +- ✅ `log` - Discord logging function + +#### Permissions (`packages/utils/src/permissions.ts`) +- ✅ `hasPermission` +- ✅ `controlPerms` (with `or` and `and` methods) +- ✅ `isJudgeAdmin` +- ✅ `getJudgeSessionFromCookie` +- ✅ `getPermsAsList` + +#### Time Utilities (`packages/utils/src/time.ts`) +- ✅ `formatHourTime` +- ✅ `formatDateRange` + +#### Other Utilities +- ✅ `logger` (`packages/utils/src/logger.ts`) - Console logger wrapper +- ✅ `stripe` (`packages/utils/src/stripe.ts`) - Stripe client +- ✅ `env` (`packages/utils/src/env.ts`) - Environment variables + +### 3. Updated Imports Across Codebase +- ✅ All API package routers now import from `@forge/utils` +- ✅ Auth package updated to use `@forge/utils` +- ✅ Email package updated to use `@forge/utils` +- ✅ DB scripts updated to use `@forge/utils` +- ✅ No remaining imports from old `../utils` path in API package + +### 4. Email Package Migration +- ✅ Moved `sendEmail` function to `@forge/email` package +- ✅ Updated email package to use `@forge/utils` logger + +## What's Left To Do ⚠️ + +### 1. Duplicate Functions (High Priority) + +#### `formatDateRange` - NAMING CONFLICT ⚠️ +- **Location 1**: `apps/blade/src/lib/utils.ts:29` + - Formats date ranges: "Jan 1 - Jan 15, 2024" (dates only) + - Uses `toLocaleDateString` with month/day/year +- **Location 2**: `packages/utils/src/time.ts:36` + - Formats time ranges: "9:00am - 5:00pm" (times only) + - Uses `formatHourTime` helper +- **Status**: These are DIFFERENT functions with the same name! +- **Action Required**: + - Rename one of them to avoid confusion + - Recommended: Rename utils version to `formatTimeRange` (more accurate) + - Or: Rename blade version to `formatDateRangeOnly` or similar + - These serve different purposes and both should exist + +#### `getPermsAsList` +- **Location 1**: `apps/blade/src/lib/utils.ts:120` +- **Location 2**: `packages/utils/src/permissions.ts:95` +- **Status**: Function exists in both places +- **Used in**: + - `apps/blade/src/app/_components/admin/roles/roleedit.tsx` + - `apps/blade/src/app/_components/admin/roles/roletable.tsx` + - `apps/blade/src/app/_components/navigation/session-navbar.tsx` +- **Action Required**: + - Update all imports in blade app to use `@forge/utils` + - Remove duplicate definition from `apps/blade/src/lib/utils.ts` + +### 2. Remaining Functions in Old `packages/api/src/utils.ts` + +The following functions are still in the old utils file and may need to be migrated or kept: + +- `gmail` - Google Gmail API client (may stay in API package) +- `calendar` - Google Calendar API client (may stay in API package) +- `generateJsonSchema` - Form schema generation (form-specific, may stay) +- `regenerateMediaUrls` - Form media URL regeneration (form-specific, may stay) +- `CreateFormSchema` - Form schema type (form-specific, may stay) +- `createForm` - Form creation function (form-specific, may stay) + +**Decision Needed**: These are form-specific utilities. Should they: +1. Stay in API package (recommended - they're domain-specific) +2. Move to a separate `@forge/forms` package +3. Move to `@forge/utils` (not recommended - too domain-specific) + +### 3. Other App-Specific Utils + +#### `apps/blade/src/lib/utils.ts` +Contains app-specific utilities that should likely stay: +- `formatDateTime` - Blade-specific date formatting +- `getFormattedDate` - Blade-specific date formatting +- `getTagColor` - Event tag color mapping (Blade-specific) +- `getClassTeam` - Hackathon class team mapping (Blade-specific) +- `extractProcedures` - tRPC procedure extraction (Blade-specific) + +**Status**: These are app-specific and should remain in the blade app. + +## Migration Statistics + +- **Old utils.ts exports**: 6 items (mostly form-specific) +- **New @forge/utils exports**: 21 items +- **Files importing from old utils**: 0 ✅ +- **Files importing from new utils**: 23 ✅ +- **Duplicate utility functions**: 2 ⚠️ + +## Next Steps + +1. **Immediate Actions**: + - [ ] **Resolve naming conflict**: Rename `formatDateRange` in `@forge/utils` to `formatTimeRange` (or rename blade version) + - [ ] Update `apps/blade/src/lib/utils.ts` to import `getPermsAsList` from `@forge/utils` + - [ ] Update all blade app files using `getPermsAsList` to import from `@forge/utils` + - [ ] Remove duplicate `getPermsAsList` definition from `apps/blade/src/lib/utils.ts` + +2. **Verification**: + - [ ] Test all affected components after migration + - [ ] Run static analysis again to confirm no duplicates remain + - [ ] Verify both date/time formatting functions work correctly after renaming + +3. **Documentation**: + - [ ] Update any documentation referencing old utils paths + - [ ] Document which utilities belong in `@forge/utils` vs app-specific utils + +## Running Analysis + +To re-run the analysis scripts: + +```bash +# Find duplicate functions and code blocks +npx tsx scripts/analyze-duplicates.ts + +# Find utils migration status +npx tsx scripts/analyze-utils-migration.ts +``` diff --git a/apps/blade/package.json b/apps/blade/package.json index 0b9eec8ea..25cf77499 100644 --- a/apps/blade/package.json +++ b/apps/blade/package.json @@ -26,6 +26,7 @@ "@forge/db": "workspace:*", "@forge/email": "workspace:^", "@forge/ui": "workspace:*", + "@forge/utils": "workspace:*", "@forge/validators": "workspace:*", "@react-email/render": "1.1.0", "@stripe/react-stripe-js": "^3.0.0", diff --git a/apps/blade/src/app/_components/admin/club/events/event-details.tsx b/apps/blade/src/app/_components/admin/club/events/event-details.tsx index 719058784..cd4239f93 100644 --- a/apps/blade/src/app/_components/admin/club/events/event-details.tsx +++ b/apps/blade/src/app/_components/admin/club/events/event-details.tsx @@ -15,8 +15,7 @@ import { DialogTitle, DialogTrigger, } from "@forge/ui/dialog"; - -import { formatDateTime, getTagColor } from "~/lib/utils"; +import { events, time } from "@forge/utils"; export function EventDetailsButton({ event, @@ -41,12 +40,14 @@ export function EventDetailsButton({
{event.name} - + {event.tag} {event.hackathonName && ( {event.hackathonName} @@ -63,14 +64,14 @@ export function EventDetailsButton({
Start - {formatDateTime(event.start_datetime)} + {time.formatDateTime(event.start_datetime)}
End - {formatDateTime(event.end_datetime)} + {time.formatDateTime(event.end_datetime)}
diff --git a/apps/blade/src/app/_components/admin/club/events/events-table.tsx b/apps/blade/src/app/_components/admin/club/events/events-table.tsx index b8df2f7af..57e2d5e61 100644 --- a/apps/blade/src/app/_components/admin/club/events/events-table.tsx +++ b/apps/blade/src/app/_components/admin/club/events/events-table.tsx @@ -15,9 +15,9 @@ import { TableHeader, TableRow, } from "@forge/ui/table"; +import { time } from "@forge/utils"; import SortButton from "~/app/_components/shared/SortButton"; -import { getFormattedDate } from "~/lib/utils"; import { api } from "~/trpc/react"; import { CreateEventButton } from "./create-event"; import { DeleteEventButton } from "./delete-event"; @@ -188,7 +188,7 @@ export function EventsTable() { {event.tag} - {getFormattedDate(event.start_datetime)} + {time.getFormattedDate(event.start_datetime)} {event.location} @@ -244,7 +244,7 @@ export function EventsTable() { {event.tag} - {getFormattedDate(event.start_datetime)} + {time.getFormattedDate(event.start_datetime)} {event.location} diff --git a/apps/blade/src/app/_components/admin/club/events/view-attendance-button.tsx b/apps/blade/src/app/_components/admin/club/events/view-attendance-button.tsx index a4fa02a95..52b13d6d9 100644 --- a/apps/blade/src/app/_components/admin/club/events/view-attendance-button.tsx +++ b/apps/blade/src/app/_components/admin/club/events/view-attendance-button.tsx @@ -52,6 +52,7 @@ function Attendees({ eventId }: { eventId: string }) { } invalidateAttendees().catch((error) => { + // TODO: look into not using the console // eslint-disable-next-line no-console console.error( "Error invalidating members in gathering attendees: ", diff --git a/apps/blade/src/app/_components/admin/club/members/member-profile.tsx b/apps/blade/src/app/_components/admin/club/members/member-profile.tsx index 514bd10ae..b4daed8b8 100644 --- a/apps/blade/src/app/_components/admin/club/members/member-profile.tsx +++ b/apps/blade/src/app/_components/admin/club/members/member-profile.tsx @@ -32,6 +32,7 @@ export default function MemberProfileButton({ } invalidateMembers().catch((error) => { + // TODO: why are we logging to the browser console // eslint-disable-next-line no-console console.error("Error invalidating members in member profile: ", error); }); diff --git a/apps/blade/src/app/_components/admin/club/members/scanner.tsx b/apps/blade/src/app/_components/admin/club/members/scanner.tsx index 2c1b43cae..071384c84 100644 --- a/apps/blade/src/app/_components/admin/club/members/scanner.tsx +++ b/apps/blade/src/app/_components/admin/club/members/scanner.tsx @@ -26,9 +26,9 @@ import { } from "@forge/ui/form"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@forge/ui/tabs"; import { toast } from "@forge/ui/toast"; +import { hackathons } from "@forge/utils"; import ToggleButton from "~/app/_components/admin/hackathon/hackers/toggle-button"; -import { getClassTeam } from "~/lib/utils"; import { api } from "~/trpc/react"; const ScannerPopUp = ({ eventType }: { eventType: "Member" | "Hacker" }) => { @@ -337,8 +337,9 @@ const ScannerPopUp = ({ eventType }: { eventType: "Member" | "Hacker" }) => {
{assignedClass} diff --git a/apps/blade/src/app/_components/admin/forms/editor/client.tsx b/apps/blade/src/app/_components/admin/forms/editor/client.tsx index 3eecdca26..d02269b21 100644 --- a/apps/blade/src/app/_components/admin/forms/editor/client.tsx +++ b/apps/blade/src/app/_components/admin/forms/editor/client.tsx @@ -25,6 +25,7 @@ import { CSS } from "@dnd-kit/utilities"; import { ArrowLeft, CogIcon, Loader2, Plus, Save, Users } from "lucide-react"; import type { FORMS } from "@forge/consts"; +import type { trpc } from "@forge/utils"; import { Button } from "@forge/ui/button"; import { Card } from "@forge/ui/card"; import { Checkbox } from "@forge/ui/checkbox"; @@ -52,13 +53,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@forge/ui/tabs"; import { Textarea } from "@forge/ui/textarea"; import type { MatchingType } from "./linker"; -import type { ProcedureMeta } from "~/lib/utils"; import { InstructionEditCard } from "~/app/_components/forms/shared/instruction-edit-card"; import { QuestionEditCard } from "~/app/_components/forms/shared/question-edit-card"; import { api } from "~/trpc/react"; import { ConnectionViewer } from "./con-viewer"; import ListMatcher from "./linker"; +type ProcedureMeta = trpc.ProcedureMeta; + type FormQuestion = z.infer; type FormInstruction = z.infer; type UIQuestion = FormQuestion & { id: string }; diff --git a/apps/blade/src/app/_components/admin/forms/editor/linker.tsx b/apps/blade/src/app/_components/admin/forms/editor/linker.tsx index cb6b3c597..4e6343e89 100644 --- a/apps/blade/src/app/_components/admin/forms/editor/linker.tsx +++ b/apps/blade/src/app/_components/admin/forms/editor/linker.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { Loader2 } from "lucide-react"; import { z } from "zod"; +import type { trpc } from "@forge/utils"; import { Button } from "@forge/ui/button"; import { Input } from "@forge/ui/input"; import { Label } from "@forge/ui/label"; @@ -16,9 +17,10 @@ import { } from "@forge/ui/select"; import { toast } from "@forge/ui/toast"; -import type { ProcedureMeta } from "~/lib/utils"; import { api } from "~/trpc/react"; +type ProcedureMeta = trpc.ProcedureMeta; + const matchingSchema = z.object({ proc: z.string().optional(), form: z.string().optional(), diff --git a/apps/blade/src/app/_components/admin/hackathon/events/event-details.tsx b/apps/blade/src/app/_components/admin/hackathon/events/event-details.tsx index 719058784..cd4239f93 100644 --- a/apps/blade/src/app/_components/admin/hackathon/events/event-details.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/events/event-details.tsx @@ -15,8 +15,7 @@ import { DialogTitle, DialogTrigger, } from "@forge/ui/dialog"; - -import { formatDateTime, getTagColor } from "~/lib/utils"; +import { events, time } from "@forge/utils"; export function EventDetailsButton({ event, @@ -41,12 +40,14 @@ export function EventDetailsButton({
{event.name} - + {event.tag} {event.hackathonName && ( {event.hackathonName} @@ -63,14 +64,14 @@ export function EventDetailsButton({
Start - {formatDateTime(event.start_datetime)} + {time.formatDateTime(event.start_datetime)}
End - {formatDateTime(event.end_datetime)} + {time.formatDateTime(event.end_datetime)}
diff --git a/apps/blade/src/app/_components/admin/hackathon/events/events-table.tsx b/apps/blade/src/app/_components/admin/hackathon/events/events-table.tsx index 403ebceed..80172b499 100644 --- a/apps/blade/src/app/_components/admin/hackathon/events/events-table.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/events/events-table.tsx @@ -15,9 +15,9 @@ import { TableHeader, TableRow, } from "@forge/ui/table"; +import { time } from "@forge/utils"; import SortButton from "~/app/_components/shared/SortButton"; -import { getFormattedDate } from "~/lib/utils"; import { api } from "~/trpc/react"; import { CreateEventButton } from "./create-event"; import { DeleteEventButton } from "./delete-event"; @@ -194,7 +194,7 @@ export function EventsTable() { - {getFormattedDate(event.start_datetime)} + {time.getFormattedDate(event.start_datetime)} {event.location} @@ -251,7 +251,7 @@ export function EventsTable() { - {getFormattedDate(event.start_datetime)} + {time.getFormattedDate(event.start_datetime)} {event.location} diff --git a/apps/blade/src/app/_components/admin/hackathon/events/view-attendance-button.tsx b/apps/blade/src/app/_components/admin/hackathon/events/view-attendance-button.tsx index ea34deaad..35fecb6df 100644 --- a/apps/blade/src/app/_components/admin/hackathon/events/view-attendance-button.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/events/view-attendance-button.tsx @@ -51,6 +51,7 @@ function Attendees({ eventId }: { eventId: string }) { } invalidateAttendees().catch((error) => { + // TODO: look into not logging into the console // eslint-disable-next-line no-console console.error( "Error invalidating members in gathering attendees: ", diff --git a/apps/blade/src/app/_components/admin/hackathon/judge-assignment/judges-client.tsx b/apps/blade/src/app/_components/admin/hackathon/judge-assignment/judges-client.tsx index f02614c0a..bcc5c58d9 100644 --- a/apps/blade/src/app/_components/admin/hackathon/judge-assignment/judges-client.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/judge-assignment/judges-client.tsx @@ -81,6 +81,7 @@ export default function QRCodesClient() { setSelectedRoom(null); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); + // TODO: look into not logging into the console // eslint-disable-next-line no-console console.error("Failed to generate room:", err); alert(`Failed to generate room: ${message}`); diff --git a/apps/blade/src/app/_components/admin/roles/roleedit.tsx b/apps/blade/src/app/_components/admin/roles/roleedit.tsx index ca26209fc..db13e3390 100644 --- a/apps/blade/src/app/_components/admin/roles/roleedit.tsx +++ b/apps/blade/src/app/_components/admin/roles/roleedit.tsx @@ -20,8 +20,8 @@ import { import { Input } from "@forge/ui/input"; import { Label } from "@forge/ui/label"; import { toast } from "@forge/ui/toast"; +import { permissions } from "@forge/utils"; -import { getPermsAsList } from "~/lib/utils"; import { api } from "~/trpc/react"; export default function RoleEdit({ @@ -296,7 +296,7 @@ export default function RoleEdit({
-
{`${getPermsAsList(permString).length} permission(s) applied`}
+
{`${permissions.getPermsAsList(permString).length} permission(s) applied`}
@@ -147,7 +147,7 @@ export default function RoleTable() { This role has the following permissions:
    - {getPermsAsList(v.permissions).map((p) => { + {permissions.getPermsAsList(v.permissions).map((p) => { return (
  • {p} diff --git a/apps/blade/src/app/_components/dashboard/hackathon-dashboard/point-leaderboard.tsx b/apps/blade/src/app/_components/dashboard/hackathon-dashboard/point-leaderboard.tsx index 0e95103f5..8ad25122e 100644 --- a/apps/blade/src/app/_components/dashboard/hackathon-dashboard/point-leaderboard.tsx +++ b/apps/blade/src/app/_components/dashboard/hackathon-dashboard/point-leaderboard.tsx @@ -5,9 +5,9 @@ import { Dot, Loader } from "lucide-react"; import type { HackerClass } from "@forge/db/schemas/knight-hacks"; import { HACKER_TEAMS } from "@forge/db/schemas/knight-hacks"; +import { hackathons } from "@forge/utils"; import type { api as serverCall } from "~/trpc/server"; -import { getClassTeam } from "~/lib/utils"; import { api } from "~/trpc/react"; interface LeaderboardEntry { @@ -38,7 +38,7 @@ export function PointLeaderboard({ hPoints: hacker?.points || 0, hClass: hacker?.class || "Alchemist", }); - const team = getClassTeam(hacker?.class || "Alchemist"); + const team = hackathons.getClassTeam(hacker?.class || "Alchemist"); const [overall, setOverall] = useState(); const [showYours, setShowYours] = useState(false); @@ -143,7 +143,7 @@ export function PointLeaderboard({
    {!activeTop ? ( dummy.map((v, i) => { - const t = getClassTeam(v); + const t = hackathons.getClassTeam(v); return (
    {activeTop.map((v, i) => { - const t = getClassTeam(v.class || "Alchemist"); + const t = hackathons.getClassTeam(v.class || "Alchemist"); return (
    ([0, 0]); - const team = getClassTeam(hClass); + const team = hackathons.getClassTeam(hClass); function formatPts(pt: number) { const fmt = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 }); diff --git a/apps/blade/src/app/_components/dashboard/hackathon-dashboard/upcoming-events.tsx b/apps/blade/src/app/_components/dashboard/hackathon-dashboard/upcoming-events.tsx index 01e9edd26..f11b6c1ef 100644 --- a/apps/blade/src/app/_components/dashboard/hackathon-dashboard/upcoming-events.tsx +++ b/apps/blade/src/app/_components/dashboard/hackathon-dashboard/upcoming-events.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { Star } from "lucide-react"; import { Badge } from "@forge/ui/badge"; @@ -9,8 +8,8 @@ import { CardHeader, CardTitle, } from "@forge/ui/card"; +import { time } from "@forge/utils"; -import { formatDateTime } from "~/lib/utils"; import { api } from "~/trpc/server"; export default async function UpcomingEvents() { @@ -52,7 +51,8 @@ export default async function UpcomingEvents() { {event.name} - {formatDateTime(event.start_datetime)} @ {event.location} + {time.formatDateTime(event.start_datetime)} @{" "} + {event.location} diff --git a/apps/blade/src/app/_components/dashboard/hacker-dashboard/past-hackathons.tsx b/apps/blade/src/app/_components/dashboard/hacker-dashboard/past-hackathons.tsx index c3d581a1e..c228ecdea 100644 --- a/apps/blade/src/app/_components/dashboard/hacker-dashboard/past-hackathons.tsx +++ b/apps/blade/src/app/_components/dashboard/hacker-dashboard/past-hackathons.tsx @@ -9,9 +9,9 @@ import { DialogTitle, DialogTrigger, } from "@forge/ui/dialog"; +import { time } from "@forge/utils"; import type { api } from "~/trpc/server"; -import { formatDateTime } from "~/lib/utils"; export function PastHackathonButton({ hackathons, @@ -80,14 +80,14 @@ export function PastHackathonButton({
    Start - {formatDateTime(hackathon.startDate)} + {time.formatDateTime(hackathon.startDate)}
    End - {formatDateTime(hackathon.endDate)} + {time.formatDateTime(hackathon.endDate)}
    diff --git a/apps/blade/src/app/_components/dashboard/hacker/hacker-application-form.tsx b/apps/blade/src/app/_components/dashboard/hacker/hacker-application-form.tsx index 66593c897..259aaac84 100644 --- a/apps/blade/src/app/_components/dashboard/hacker/hacker-application-form.tsx +++ b/apps/blade/src/app/_components/dashboard/hacker/hacker-application-form.tsx @@ -378,6 +378,7 @@ export function HackerFormPage({ }, }); } catch (error) { + // TODO: look into not logging into the console // eslint-disable-next-line no-console console.error("Error uploading resume or creating hacker:", error); toast.error( diff --git a/apps/blade/src/app/_components/dashboard/member-dashboard/download-qr-pass.tsx b/apps/blade/src/app/_components/dashboard/member-dashboard/download-qr-pass.tsx index 3161ba790..560e9c42f 100644 --- a/apps/blade/src/app/_components/dashboard/member-dashboard/download-qr-pass.tsx +++ b/apps/blade/src/app/_components/dashboard/member-dashboard/download-qr-pass.tsx @@ -45,6 +45,7 @@ export function DownloadQRPass() { toast.success("Apple Wallet pass downloaded successfully!"); } catch (error) { + // TODO: look into not logging into the console console.error("Error downloading pass:", error); // eslint-disable-line no-console toast.error("Failed to download pass"); } finally { @@ -52,6 +53,7 @@ export function DownloadQRPass() { } }, onError: (error: { message?: string }) => { + // TODO: look into not logging into the console console.error("Error generating pass:", error); // eslint-disable-line no-console toast.error(error.message ?? "Failed to generate pass"); setIsDownloading(false); diff --git a/apps/blade/src/app/_components/dashboard/member-dashboard/event/event-showcase.tsx b/apps/blade/src/app/_components/dashboard/member-dashboard/event/event-showcase.tsx index 322fb4b90..d73ddb18f 100644 --- a/apps/blade/src/app/_components/dashboard/member-dashboard/event/event-showcase.tsx +++ b/apps/blade/src/app/_components/dashboard/member-dashboard/event/event-showcase.tsx @@ -20,10 +20,10 @@ import { DialogTitle, DialogTrigger, } from "@forge/ui/dialog"; +import { events as eventUtils, time } from "@forge/utils"; import type { api } from "~/trpc/server"; import { DASHBOARD_ICON_SIZE } from "~/consts"; -import { formatDateTime, getTagColor } from "~/lib/utils"; import { EventFeedbackForm } from "./event-feedback"; export function EventShowcase({ @@ -73,7 +73,7 @@ export function EventShowcase({
    {mostRecent.tag} @@ -86,14 +86,14 @@ export function EventShowcase({
    Start - {formatDateTime(mostRecent.start_datetime)} + {time.formatDateTime(mostRecent.start_datetime)}
    End - {formatDateTime(mostRecent.end_datetime)} + {time.formatDateTime(mostRecent.end_datetime)}
    @@ -145,7 +145,7 @@ export function EventShowcase({
{event.tag} @@ -160,7 +160,7 @@ export function EventShowcase({ Start - {formatDateTime(mostRecent.start_datetime)} + {time.formatDateTime(mostRecent.start_datetime)}
@@ -169,7 +169,7 @@ export function EventShowcase({ End - {formatDateTime(mostRecent.end_datetime)} + {time.formatDateTime(mostRecent.end_datetime)} diff --git a/apps/blade/src/app/_components/forms/connection-handler.ts b/apps/blade/src/app/_components/forms/connection-handler.ts index 6ecd7a6cd..493148bcd 100644 --- a/apps/blade/src/app/_components/forms/connection-handler.ts +++ b/apps/blade/src/app/_components/forms/connection-handler.ts @@ -3,10 +3,10 @@ import { stringify } from "superjson"; import { appRouter } from "@forge/api"; -import { log } from "@forge/api/utils"; import { auth } from "@forge/auth/server"; +import { trpc } from "@forge/utils"; +import * as discord from "@forge/utils/discord"; -import { extractProcedures } from "~/lib/utils"; import { api } from "~/trpc/server"; export const handleCallbacks = async ( @@ -19,7 +19,7 @@ export const handleCallbacks = async ( if (!session) return; const connections = await api.forms.getConnections({ id }); - const procs = extractProcedures(appRouter); + const procs = trpc.extractProcedures(appRouter); for (const con of connections) { const data: Record = {}; @@ -46,7 +46,7 @@ export const handleCallbacks = async ( try { await proc(data); - await log({ + await discord.log({ title: `Successfully automatically fired procedure`, message: `**Successfully fired procedure**\n\`${con.proc}\`\n\nTriggered after **${name}** submission from **${session.user.name}**`, color: "success_green", @@ -54,7 +54,7 @@ export const handleCallbacks = async ( }); } catch (error) { const errorMessage = JSON.stringify(error, null, 2); - await log({ + await discord.log({ title: `Failed to automatically fire procedure`, message: `**Failed to fire procedure**\n\`${con.proc}\`\n\nTriggered after **${name}** submission from **${session.user.name}**\n\n**Data:**\n\`\`\`json\n${stringify(data)}\`\`\`` + diff --git a/apps/blade/src/app/_components/forms/form-responder-client.tsx b/apps/blade/src/app/_components/forms/form-responder-client.tsx index 8c637d99e..5cef9b545 100644 --- a/apps/blade/src/app/_components/forms/form-responder-client.tsx +++ b/apps/blade/src/app/_components/forms/form-responder-client.tsx @@ -5,10 +5,10 @@ import Link from "next/link"; import { CheckCircle2, Loader2, XCircle } from "lucide-react"; import type { FORMS } from "@forge/consts"; +import type * as forms from "@forge/utils/forms.client"; import { Button } from "@forge/ui/button"; import { Card } from "@forge/ui/card"; -import type { FormResponsePayload } from "./utils"; import { api } from "~/trpc/react"; import { useSubmissionSuccess } from "./_hooks/useSubmissionSuccess"; import { handleCallbacks } from "./connection-handler"; @@ -16,6 +16,8 @@ import FormNotFound from "./form-not-found"; import { FormRunner } from "./form-runner"; import { SubmissionSuccessCard } from "./form-submitted-success"; +type FormResponsePayload = forms.FormResponsePayload; + interface FormResponderWrapperProps { formName: string; userName: string; diff --git a/apps/blade/src/app/_components/forms/form-runner.tsx b/apps/blade/src/app/_components/forms/form-runner.tsx index 0f0053067..fafbdb15a 100644 --- a/apps/blade/src/app/_components/forms/form-runner.tsx +++ b/apps/blade/src/app/_components/forms/form-runner.tsx @@ -5,11 +5,13 @@ import { useEffect, useState } from "react"; import type { FORMS } from "@forge/consts"; import { Button } from "@forge/ui/button"; import { Card } from "@forge/ui/card"; +import * as forms from "@forge/utils/forms.client"; -import type { FormResponsePayload, FormResponseUI } from "./utils"; import { InstructionResponseCard } from "~/app/_components/forms/instruction-response-card"; import { QuestionResponseCard } from "~/app/_components/forms/question-response-card"; -import { getValidationError, isFormValid, normalizeResponses } from "./utils"; + +type FormResponsePayload = forms.FormResponsePayload; +type FormResponseUI = forms.FormResponseUI; /** * Shared renderer for "fill out form" and "review/edit response". @@ -82,10 +84,12 @@ export function FormRunner({ }; const canSubmit = - allowEdit && !isSubmitting && isFormValid(zodValidator, responses, form); + allowEdit && + !isSubmitting && + forms.isFormValid(zodValidator, responses, form); const handleSubmit = () => { - const payload = normalizeResponses(responses, form); + const payload = forms.normalizeResponses(responses, form); onSubmit(payload); }; @@ -167,7 +171,7 @@ export function FormRunner({ handleResponseChange(item.question, value); }} formId={formId} - error={getValidationError( + error={forms.getValidationError( item, zodValidator, responses, diff --git a/apps/blade/src/app/_components/forms/form-view-edit-client.tsx b/apps/blade/src/app/_components/forms/form-view-edit-client.tsx index f317ca349..52ee03eb0 100644 --- a/apps/blade/src/app/_components/forms/form-view-edit-client.tsx +++ b/apps/blade/src/app/_components/forms/form-view-edit-client.tsx @@ -4,8 +4,8 @@ import { useMemo, useState } from "react"; import { Loader2 } from "lucide-react"; import type { FORMS } from "@forge/consts"; +import type * as forms from "@forge/utils/forms.client"; -import type { FormResponsePayload, FormResponseUI } from "./utils"; import { api } from "~/trpc/react"; import { useSubmissionSuccess } from "./_hooks/useSubmissionSuccess"; import FormNotFound from "./form-not-found"; @@ -13,6 +13,9 @@ import { FormRunner } from "./form-runner"; import { SubmissionSuccessCard } from "./form-submitted-success"; import ResponseNotFound from "./response-not-found"; +type FormResponsePayload = forms.FormResponsePayload; +type FormResponseUI = forms.FormResponseUI; + interface FormReviewWrapperProps { formName: string; userName: string; diff --git a/apps/blade/src/app/_components/navigation/session-navbar.tsx b/apps/blade/src/app/_components/navigation/session-navbar.tsx index 08179665e..2c8bd9d10 100644 --- a/apps/blade/src/app/_components/navigation/session-navbar.tsx +++ b/apps/blade/src/app/_components/navigation/session-navbar.tsx @@ -12,8 +12,8 @@ import { NavigationMenuList, } from "@forge/ui/navigation-menu"; import { Separator } from "@forge/ui/separator"; +import { permissions } from "@forge/utils"; -import { getPermsAsList } from "~/lib/utils"; import { api } from "~/trpc/server"; import ClubLogo from "./club-logo"; import { UserDropdown } from "./user-dropdown"; @@ -26,7 +26,7 @@ export async function SessionNavbar() { permString += v ? "1" : "0"; }); - const permList = getPermsAsList(permString); + const permList = permissions.getPermsAsList(permString); return (
diff --git a/apps/blade/src/app/_components/shared/scanner.tsx b/apps/blade/src/app/_components/shared/scanner.tsx index 2c1b43cae..071384c84 100644 --- a/apps/blade/src/app/_components/shared/scanner.tsx +++ b/apps/blade/src/app/_components/shared/scanner.tsx @@ -26,9 +26,9 @@ import { } from "@forge/ui/form"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@forge/ui/tabs"; import { toast } from "@forge/ui/toast"; +import { hackathons } from "@forge/utils"; import ToggleButton from "~/app/_components/admin/hackathon/hackers/toggle-button"; -import { getClassTeam } from "~/lib/utils"; import { api } from "~/trpc/react"; const ScannerPopUp = ({ eventType }: { eventType: "Member" | "Hacker" }) => { @@ -337,8 +337,9 @@ const ScannerPopUp = ({ eventType }: { eventType: "Member" | "Hacker" }) => {
{assignedClass} diff --git a/apps/blade/src/app/admin/forms/[slug]/page.tsx b/apps/blade/src/app/admin/forms/[slug]/page.tsx index a7dc299b4..4908c0112 100644 --- a/apps/blade/src/app/admin/forms/[slug]/page.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/page.tsx @@ -2,9 +2,9 @@ import { redirect } from "next/navigation"; import { appRouter } from "@forge/api"; import { auth } from "@forge/auth/server"; +import { trpc } from "@forge/utils"; import { EditorClient } from "~/app/_components/admin/forms/editor/client"; -import { extractProcedures } from "~/lib/utils"; import { api } from "~/trpc/server"; export default async function FormEditorPage({ @@ -35,7 +35,10 @@ export default async function FormEditorPage({ return ( <> - + ); } diff --git a/apps/blade/src/app/api/membership/route.ts b/apps/blade/src/app/api/membership/route.ts index 97f754b3e..deb375633 100644 --- a/apps/blade/src/app/api/membership/route.ts +++ b/apps/blade/src/app/api/membership/route.ts @@ -1,9 +1,9 @@ -/* eslint-disable no-console */ import type { NextRequest } from "next/server"; import Stripe from "stripe"; import { db } from "@forge/db/client"; import { DuesPayment, DuesPaymentSchema } from "@forge/db/schemas/knight-hacks"; +import { logger } from "@forge/utils"; import { env } from "~/env"; @@ -11,10 +11,10 @@ async function membershipRecord(sessionId: string) { const stripe = new Stripe(env.STRIPE_SECRET_KEY, { typescript: true }); // TODO: Make this function safe to run multiple times, - // even concurrently, with the same session ID + // even concurrently, with the same session ID // TODO: Make sure fulfillment hasn't already been - // peformed for this Checkout Session + // peformed for this Checkout Session // Retrieve the Checkout Session from the API try { @@ -32,7 +32,7 @@ async function membershipRecord(sessionId: string) { }).safeParse(values); if (!validatedCheckoutFields.success) { - console.log(validatedCheckoutFields.error.issues); + logger.log(validatedCheckoutFields.error.issues); throw new Error("Invalid or missing field(s)"); } // Check the Checkout Session's payment_status property @@ -43,7 +43,7 @@ async function membershipRecord(sessionId: string) { } throw new Error("Checkout session payment status is unpaid"); } catch (e) { - console.error("Error:", e); + logger.error("Error:", e); return false; } } diff --git a/apps/blade/src/app/api/trpc/[trpc]/route.ts b/apps/blade/src/app/api/trpc/[trpc]/route.ts index a969026b8..63541474c 100644 --- a/apps/blade/src/app/api/trpc/[trpc]/route.ts +++ b/apps/blade/src/app/api/trpc/[trpc]/route.ts @@ -60,6 +60,7 @@ const handler = async (req: Request) => { headers: req.headers, }), onError({ error, path }) { + // TODO: look into not logging into the console // eslint-disable-next-line no-console console.error(`>>> tRPC Error on '${path}'`, error.message); }, diff --git a/apps/blade/src/app/judge/session/route.ts b/apps/blade/src/app/judge/session/route.ts index 757a18ca6..10516daa8 100644 --- a/apps/blade/src/app/judge/session/route.ts +++ b/apps/blade/src/app/judge/session/route.ts @@ -1,10 +1,10 @@ // apps/blade/app/api/judge/session/route.ts import { NextResponse } from "next/server"; -import { getJudgeSessionFromCookie } from "../../../../../../packages/api/src/utils"; +import * as permissionsServer from "@forge/utils/permissions.server"; export async function GET() { - const row = await getJudgeSessionFromCookie(); + const row = await permissionsServer.getJudgeSessionFromCookie(); if (!row) return NextResponse.json({ ok: false }, { status: 401 }); return NextResponse.json({ ok: true, roomName: row.roomName }); } diff --git a/apps/blade/src/lib/utils.ts b/apps/blade/src/lib/utils.ts deleted file mode 100644 index 842737ff5..000000000 --- a/apps/blade/src/lib/utils.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { AnyTRPCProcedure, AnyTRPCRouter } from "@trpc/server"; -import type { z } from "zod"; - -import type { EVENTS } from "@forge/consts"; -import type { HackerClass } from "@forge/db/schemas/knight-hacks"; -import { PERMISSIONS } from "@forge/consts"; - -export const formatDateTime = (date: Date) => { - // Create a new Date object 5 hours behind the original - const adjustedDate = new Date(date.getTime()); - adjustedDate.setDate(adjustedDate.getDate() + 1); - - return adjustedDate.toLocaleString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - hour12: true, - }); -}; - -export const getFormattedDate = (start_datetime: string | Date) => { - const date = new Date(start_datetime); - date.setDate(date.getDate() + 1); - return date.toLocaleDateString(); -}; - -export const formatDateRange = (startDate: Date, endDate: Date) => { - const start = new Date(startDate).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); - const end = new Date(endDate).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - return `${start} - ${end}`; -}; - -export const getTagColor = (tag: EVENTS.EventTagsColor) => { - const colors: Record = { - GBM: "bg-blue-100 text-blue-800", - Social: "bg-pink-100 text-pink-800", - Kickstart: "bg-green-100 text-green-800", - "Project Launch": "bg-purple-100 text-purple-800", - "Hello World": "bg-yellow-100 text-yellow-800", - Sponsorship: "bg-orange-100 text-orange-800", - "Tech Exploration": "bg-cyan-100 text-cyan-800", - "Class Support": "bg-indigo-100 text-indigo-800", - Workshop: "bg-teal-100 text-teal-800", - OPS: "bg-purple-100 text-purple-800", - Hackathon: "bg-violet-100 text-violet-800", - Collabs: "bg-red-100 text-red-800", - "Check-in": "bg-gray-100 text-gray-800", - Ceremony: "bg-amber-100 text-amber-800", - Merch: "bg-lime-100 text-lime-800", - Food: "bg-rose-100 text-rose-800", - "CAREER-FAIR": "bg-lime-100 text-lime-800", // change later - "RSO-FAIR": "bg-lime-100 text-lime-800", // change later - }; - return colors[tag]; -}; - -export const getClassTeam = (tag: HackerClass) => { - if (["Harbinger", "Alchemist", "Monstologist"].includes(tag)) { - return { - team: "Monstrosity", - teamColor: "#e03131", - imgUrl: "/khviii/lenneth.jpg", - }; - } - return { - team: "Humanity", - teamColor: "#228be6", - imgUrl: "/khviii/tkhero.jpg", - }; -}; - -export interface ProcedureMeta { - inputSchema: string[]; - route: string; -} - -interface ProcedureMetaOriginal { - id: string; - /* eslint-disable @typescript-eslint/no-explicit-any */ - inputSchema: z.ZodObject; -} - -function hasSchemaMeta(meta: unknown): meta is ProcedureMetaOriginal { - return ( - typeof meta === "object" && - meta !== null && - "id" in meta && - "inputSchema" in meta - ); -} - -export function extractProcedures(router: AnyTRPCRouter) { - const procedures: Record = {}; - - /* eslint-disable @typescript-eslint/no-unsafe-argument */ - for (const [procKey, proc] of Object.entries(router._def.procedures)) { - const procTyped = proc as AnyTRPCProcedure; - - const meta = procTyped._def.meta; - if (!hasSchemaMeta(meta)) continue; - - procedures[meta.id] = { - inputSchema: Object.keys(meta.inputSchema.shape), - route: procKey, - }; - } - - return procedures; -} - -export function getPermsAsList(perms: string) { - const list = []; - const permKeys = Object.keys(PERMISSIONS.PERMISSIONS); - for (let i = 0; i < perms.length; i++) { - const permKey = permKeys.at(i); - if (perms[i] == "1" && permKey) { - const permissionData = PERMISSIONS.PERMISSION_DATA[permKey]; - if (permissionData) list.push(permissionData.name); - } - } - return list; -} diff --git a/apps/club/package.json b/apps/club/package.json index c58058758..172dc94aa 100644 --- a/apps/club/package.json +++ b/apps/club/package.json @@ -20,6 +20,7 @@ "@forge/consts": "workspace:*", "@forge/db": "workspace:*", "@forge/ui": "workspace:*", + "@forge/utils": "workspace:*", "@gsap/react": "^2.1.2", "@svgr/webpack": "^8.1.0", "framer-motion": "^12.0.1", diff --git a/apps/club/src/app/_components/contact/contact-form.tsx b/apps/club/src/app/_components/contact/contact-form.tsx index 9f84b582f..904a4363e 100644 --- a/apps/club/src/app/_components/contact/contact-form.tsx +++ b/apps/club/src/app/_components/contact/contact-form.tsx @@ -36,6 +36,7 @@ function ContactForm() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const jsonFormData = JSON.stringify(formData); + // TODO: look into not logging into the console // eslint-disable-next-line no-console console.log("The Form:", jsonFormData); }; diff --git a/apps/club/src/app/_components/landing/calendar.tsx b/apps/club/src/app/_components/landing/calendar.tsx index edd01141f..8f5e19fab 100644 --- a/apps/club/src/app/_components/landing/calendar.tsx +++ b/apps/club/src/app/_components/landing/calendar.tsx @@ -9,13 +9,14 @@ import { Calendar, List } from "rsuite"; import type { RouterOutputs } from "@forge/api"; import type { ReturnEvent } from "@forge/db/schemas/knight-hacks"; -import { formatDateRange } from "~/lib/utils"; import NeonTkSVG from "./assets/neon-tk"; import SwordSVG from "./assets/sword"; import TerminalSVG from "./assets/terminal"; import "rsuite/Calendar/styles/index.css"; +import { time } from "@forge/utils"; + export default function CalendarEventsPage({ events, }: { @@ -93,7 +94,7 @@ export default function CalendarEventsPage({ >
- {formatDateRange(item.start_datetime, item.end_datetime)} + {time.formatTimeRange(item.start_datetime, item.end_datetime)} {item.name}
@@ -102,6 +103,7 @@ export default function CalendarEventsPage({ ); }; + const handleSelect = (date: Date) => { setSelectedDate(date); }; diff --git a/apps/club/src/lib/utils.ts b/apps/club/src/lib/utils.ts deleted file mode 100644 index ca5846046..000000000 --- a/apps/club/src/lib/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function formatHourTime(date: Date): string { - const hours = date.getHours(); - const minutes = date.getMinutes(); - const ampm = hours >= 12 ? "pm" : "am"; - - // Convert hours to 12-hour format - const formattedHours = hours % 12 || 12; - // Pad minutes with leading zero if necessary - const formattedMinutes = minutes.toString().padStart(2, "0"); - - return `${formattedHours}:${formattedMinutes}${ampm}`; -} - -export const formatDateRange = (startDate: Date, endDate: Date) => { - const start = formatHourTime(startDate); - const end = formatHourTime(endDate); - return `${start} - ${end}`; -}; diff --git a/apps/cron/eslint.config.js b/apps/cron/eslint.config.js index b2960a0c3..d2dcb3e01 100644 --- a/apps/cron/eslint.config.js +++ b/apps/cron/eslint.config.js @@ -7,9 +7,4 @@ export default [ }, ...baseConfig, ...restrictEnvAccess, - { - rules: { - "no-console": "off", - }, - }, ]; diff --git a/apps/cron/package.json b/apps/cron/package.json index e716a1cdc..6fb213126 100644 --- a/apps/cron/package.json +++ b/apps/cron/package.json @@ -17,6 +17,7 @@ "@forge/api": "workspace:*", "@forge/consts": "workspace:*", "@forge/db": "workspace:*", + "@forge/utils": "workspace:*", "@forge/validators": "workspace:*", "@t3-oss/env-core": "^0.11.1", "discord.js": "^14.16.3", diff --git a/apps/cron/src/crons/_example.ts b/apps/cron/src/crons/_example.ts index 050665d90..5574740ba 100644 --- a/apps/cron/src/crons/_example.ts +++ b/apps/cron/src/crons/_example.ts @@ -1,3 +1,5 @@ +import { logger } from "@forge/utils"; + import { CronBuilder } from "../structs/CronBuilder"; export const testCron = new CronBuilder({ @@ -6,6 +8,6 @@ export const testCron = new CronBuilder({ }).addCron( "* * * * * ", // every minute () => { - console.log("This is an example cron that runs every minute"); + logger.log("This is an example cron that runs every minute"); }, ); diff --git a/apps/cron/src/crons/alumni-assign.ts b/apps/cron/src/crons/alumni-assign.ts index bc017b3a0..b463d1f98 100644 --- a/apps/cron/src/crons/alumni-assign.ts +++ b/apps/cron/src/crons/alumni-assign.ts @@ -1,13 +1,10 @@ import { and, gt, isNotNull, isNull, lte, or } from "drizzle-orm"; -import { - addRoleToMember, - removeRoleFromMember, - resolveDiscordUserId, -} from "@forge/api/utils"; import { DISCORD } from "@forge/consts"; import { db } from "@forge/db/client"; import { Member } from "@forge/db/schemas/knight-hacks"; +import { logger } from "@forge/utils"; +import * as discord from "@forge/utils/discord"; import { CronBuilder } from "../structs/CronBuilder"; @@ -27,10 +24,11 @@ export const alumniAssign = new CronBuilder({ for (const { discordUser } of graduatedMembers) { try { - const discordId = await resolveDiscordUserId(discordUser); - if (discordId) await addRoleToMember(discordId, DISCORD.ALUMNI_ROLE); + const discordId = await discord.resolveDiscordUserId(discordUser); + if (discordId) + await discord.addRoleToMember(discordId, DISCORD.ALUMNI_ROLE); } catch (err) { - console.error(`Failed to add alumni role for ${discordUser}:`, err); + logger.error(`Failed to add alumni role for ${discordUser}:`, err); } } @@ -49,11 +47,11 @@ export const alumniAssign = new CronBuilder({ for (const { discordUser } of nonGraduatedMembers) { try { - const discordId = await resolveDiscordUserId(discordUser); + const discordId = await discord.resolveDiscordUserId(discordUser); if (discordId) - await removeRoleFromMember(discordId, DISCORD.ALUMNI_ROLE); + await discord.removeRoleFromMember(discordId, DISCORD.ALUMNI_ROLE); } catch (err) { - console.error(`Failed to remove alumni role for ${discordUser}:`, err); + logger.error(`Failed to remove alumni role for ${discordUser}:`, err); } } }, diff --git a/apps/cron/src/crons/animals.ts b/apps/cron/src/crons/animals.ts index af76ab0f5..e70f951ce 100644 --- a/apps/cron/src/crons/animals.ts +++ b/apps/cron/src/crons/animals.ts @@ -7,6 +7,7 @@ import sharp from "sharp"; import { db } from "@forge/db/client"; import { Permissions } from "@forge/db/schemas/auth"; import { Member } from "@forge/db/schemas/knight-hacks"; +import { logger } from "@forge/utils"; import { env } from "../env"; import { CronBuilder } from "../structs/CronBuilder"; @@ -102,7 +103,7 @@ export const goat = new CronBuilder({ ].find((u) => u?.trim()); const name = replaceName(`${goat.firstName} ${goat.lastName}`); - console.log("goat chosen: ", name); + logger.log("goat chosen: ", name); const embed = await createEmbed( goat.profilePictureUrl, diff --git a/apps/cron/src/crons/backup-filtered-db.ts b/apps/cron/src/crons/backup-filtered-db.ts index acec10dab..9f648dc24 100644 --- a/apps/cron/src/crons/backup-filtered-db.ts +++ b/apps/cron/src/crons/backup-filtered-db.ts @@ -1,6 +1,8 @@ import { spawn } from "child_process"; import { createInterface } from "readline/promises"; +import { logger } from "@forge/utils"; + import { CronBuilder } from "../structs/CronBuilder"; const COMMAND = "pnpm"; @@ -36,7 +38,7 @@ export const backupFilteredDb = new CronBuilder({ input: stream, crlfDelay: Infinity, })) { - if (line) console[key](line); + if (line) logger[key](line); } }), ); diff --git a/apps/cron/src/crons/leetcode.ts b/apps/cron/src/crons/leetcode.ts index 2c839badb..bf3554db8 100644 --- a/apps/cron/src/crons/leetcode.ts +++ b/apps/cron/src/crons/leetcode.ts @@ -2,7 +2,7 @@ import type { APIThreadChannel } from "discord-api-types/v10"; import { Routes, ThreadAutoArchiveDuration } from "discord-api-types/v10"; import { WebhookClient } from "discord.js"; -import { discord } from "@forge/api/utils"; +import * as discord from "@forge/utils/discord"; import { env } from "../env"; import { CronBuilder } from "../structs/CronBuilder"; @@ -90,12 +90,15 @@ export const leetcode = new CronBuilder({ embeds: [problemEmbed], }); - const thread = (await discord.post(Routes.threads(msg.channel_id, msg.id), { - body: { - name: dateString, - auto_archive_duration: ThreadAutoArchiveDuration.OneDay, + const thread = (await discord.api.post( + Routes.threads(msg.channel_id, msg.id), + { + body: { + name: dateString, + auto_archive_duration: ThreadAutoArchiveDuration.OneDay, + }, }, - })) as APIThreadChannel; + )) as APIThreadChannel; await LEETCODE_WEBHOOK.send({ content: "Make sure to wrap your solution with spoiler tags!", diff --git a/apps/cron/src/crons/reminder.ts b/apps/cron/src/crons/reminder.ts index 545353c91..6b22b147d 100644 --- a/apps/cron/src/crons/reminder.ts +++ b/apps/cron/src/crons/reminder.ts @@ -5,6 +5,7 @@ import { asc } from "drizzle-orm"; import { db } from "@forge/db/client"; import { Event } from "@forge/db/schemas/knight-hacks"; +import { logger } from "@forge/utils"; import { env } from "../env"; import { CronBuilder } from "../structs/CronBuilder"; @@ -137,11 +138,11 @@ function genCronLogic(webhook: WebhookClient): () => Promise { 0, ); - console.log(`Found a total of ${totalEvents} events`); + logger.log(`Found a total of ${totalEvents} events`); for (const group of groupedPrefixes) { - console.log(`Events for ${group.prefix}`); + logger.log(`Events for ${group.prefix}`); for (const event of group.events) { - console.log(`Title: ${event.name}`); + logger.log(`Title: ${event.name}`); } } @@ -386,21 +387,21 @@ async function getEvents() { event.end_datetime < todayEnd && event.start_datetime >= todayStart, ); - console.log("Today's Events: ", todayEvents); + logger.log("Today's Events: ", todayEvents); const tomorrowEvents = allEvents.filter( (event) => event.end_datetime < tomorrowEnd && event.start_datetime >= tomorrowStart, ); - console.log("Tomorrow's Events: ", tomorrowEvents); + logger.log("Tomorrow's Events: ", tomorrowEvents); const nextWeekEvents = allEvents.filter( (event) => event.end_datetime < nextWeekEnd && event.start_datetime >= nextWeekStart, ); - console.log("Next Week's Events: ", nextWeekEvents); + logger.log("Next Week's Events: ", nextWeekEvents); // Filter out "Operations Meeting" and "Project Launch Lab Hours" from nextWeek const nextWeekFiltered = nextWeekEvents.filter((event) => { diff --git a/apps/cron/src/crons/role-sync.ts b/apps/cron/src/crons/role-sync.ts index 02946a487..36c1a2307 100644 --- a/apps/cron/src/crons/role-sync.ts +++ b/apps/cron/src/crons/role-sync.ts @@ -1,11 +1,12 @@ import type { APIGuildMember } from "discord-api-types/v10"; import { Routes } from "discord-api-types/v10"; -import { discord } from "@forge/api/utils"; import { DISCORD } from "@forge/consts"; import { eq } from "@forge/db"; import { db } from "@forge/db/client"; import { Permissions, Roles, User } from "@forge/db/schemas/auth"; +import { logger } from "@forge/utils"; +import * as discord from "@forge/utils/discord"; import { CronBuilder } from "../structs/CronBuilder"; @@ -24,11 +25,11 @@ export const roleSync = new CronBuilder({ async () => { // Get all roles that are linked in Blade const linkedRoles = await db.select().from(Roles); - console.log(`Found ${linkedRoles.length} linked roles`); + logger.log(`Found ${linkedRoles.length} linked roles`); // Get all users in Blade const users = await db.select().from(User); - console.log(`Checking ${users.length} users`); + logger.log(`Checking ${users.length} users`); let addedCount = 0; let removedCount = 0; @@ -40,7 +41,7 @@ export const roleSync = new CronBuilder({ for (const user of users) { try { // Fetch the user's roles from Discord - const guildMember = (await discord.get( + const guildMember = (await discord.api.get( Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, user.discordUserId), )) as APIGuildMember; @@ -96,14 +97,14 @@ export const roleSync = new CronBuilder({ } } - console.log( + logger.log( `Sync completed. Added: ${addedCount}, Removed: ${removedCount}, Skipped: ${skippedCount}, Errors: ${errorCount}`, ); if (errorCount > 0) { - console.warn(`First ${erroredUsers.length} users it errored for:`); + logger.warn(`First ${erroredUsers.length} users it errored for:`); for (const name of erroredUsers) { - console.warn(name); + logger.warn(name); } } }, diff --git a/apps/cron/src/structs/CronBuilder.ts b/apps/cron/src/structs/CronBuilder.ts index 6d50b29f3..80b276705 100644 --- a/apps/cron/src/structs/CronBuilder.ts +++ b/apps/cron/src/structs/CronBuilder.ts @@ -1,6 +1,8 @@ import { AsyncLocalStorage } from "node:async_hooks"; import cron from "node-cron"; +import { logger } from "@forge/utils"; + // DO NOT TOUCH // Basically the whole point here is to override console.log such that when // we are inside of the CronBuilder AsyncLocalStorage, we add the logging info @@ -83,23 +85,23 @@ export class CronBuilder { for (const { expression, executor } of this.crons) { // eslint-disable-next-line @typescript-eslint/no-misused-promises cron.schedule(expression, this._executor.bind(this, executor)); - currentCron.run(this, () => console.log(`scheduled @ ${expression}`)); + currentCron.run(this, () => logger.log(`scheduled @ ${expression}`)); } } private async _executor(executor: ExecutorFunction): Promise { return await currentCron.run(this, async () => { const startTime = Date.now(); - console.log(`started @ ${new Date(startTime).toLocaleTimeString()}`); + logger.log(`started @ ${new Date(startTime).toLocaleTimeString()}`); try { await executor(); } catch (error) { - console.error(error); + logger.error(error); } const endTime = Date.now(); - console.log( + logger.log( `finished @ ${new Date(endTime).toLocaleTimeString()} (${endTime - startTime}ms)`, ); }); diff --git a/apps/gemiknights/src/app/_components/ui/background-gradient-animation.tsx b/apps/gemiknights/src/app/_components/ui/background-gradient-animation.tsx index 0fa14255a..cada9c779 100644 --- a/apps/gemiknights/src/app/_components/ui/background-gradient-animation.tsx +++ b/apps/gemiknights/src/app/_components/ui/background-gradient-animation.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; -import { cn } from "~/lib/utils"; +import { cn } from "@forge/ui"; export const BackgroundGradientAnimation = ({ gradientBackgroundStart = "rgb(108, 0, 162)", diff --git a/apps/gemiknights/src/lib/utils.ts b/apps/gemiknights/src/lib/utils.ts deleted file mode 100644 index 88283f013..000000000 --- a/apps/gemiknights/src/lib/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ClassValue } from "clsx"; -import { clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/apps/tk/eslint.config.js b/apps/tk/eslint.config.js index b2960a0c3..d2dcb3e01 100644 --- a/apps/tk/eslint.config.js +++ b/apps/tk/eslint.config.js @@ -7,9 +7,4 @@ export default [ }, ...baseConfig, ...restrictEnvAccess, - { - rules: { - "no-console": "off", - }, - }, ]; diff --git a/apps/tk/package.json b/apps/tk/package.json index d013f6ebe..35d7c1d15 100644 --- a/apps/tk/package.json +++ b/apps/tk/package.json @@ -16,6 +16,7 @@ "dependencies": { "@forge/consts": "workspace:*", "@forge/db": "workspace:*", + "@forge/utils": "workspace:*", "@forge/validators": "workspace:*", "@t3-oss/env-core": "^0.11.1", "discord.js": "^14.16.3", diff --git a/apps/tk/src/commands/capybara.ts b/apps/tk/src/commands/capybara.ts index 43087b66e..eec4fd53d 100644 --- a/apps/tk/src/commands/capybara.ts +++ b/apps/tk/src/commands/capybara.ts @@ -2,6 +2,8 @@ import type { CommandInteraction } from "discord.js"; import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; import JIMP from "jimp"; +import { logger } from "@forge/utils"; + import { TK_CAPYBARA_URL } from "../consts"; // CAPYBARA COMMAND @@ -46,9 +48,9 @@ export async function execute(interaction: CommandInteraction) { // catch any errors } catch (err: unknown) { if (err instanceof Error) { - console.error(err.message); + logger.error(err.message); } else { - console.error("An unknown error occurred: ", err); + logger.error("An unknown error occurred: ", err); } } } diff --git a/apps/tk/src/commands/cat.ts b/apps/tk/src/commands/cat.ts index 8912fbbe0..2bcac605f 100644 --- a/apps/tk/src/commands/cat.ts +++ b/apps/tk/src/commands/cat.ts @@ -2,6 +2,8 @@ import type { CommandInteraction } from "discord.js"; import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; import JIMP from "jimp"; +import { logger } from "@forge/utils"; + import { TK_CAT_URL } from "../consts"; // CAT COMMAND @@ -50,9 +52,9 @@ export async function execute(interaction: CommandInteraction) { // catch any errors } catch (err: unknown) { if (err instanceof Error) { - console.error(err.message); + logger.error(err.message); } else { - console.error("An unknown error occurred: ", err); + logger.error("An unknown error occurred: ", err); } } } diff --git a/apps/tk/src/commands/dog.ts b/apps/tk/src/commands/dog.ts index f772a851a..d5a1ca0dd 100644 --- a/apps/tk/src/commands/dog.ts +++ b/apps/tk/src/commands/dog.ts @@ -2,6 +2,8 @@ import type { CommandInteraction } from "discord.js"; import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; import JIMP from "jimp"; +import { logger } from "@forge/utils"; + import { TK_DOG_URL } from "../consts"; // DOG COMMAND @@ -40,9 +42,9 @@ export async function execute(interaction: CommandInteraction) { void interaction.reply({ embeds: [embed] }); } catch (err: unknown) { if (err instanceof Error) { - console.error(err.message); + logger.error(err.message); } else { - console.error("An unknown error occurred: ", err); + logger.error("An unknown error occurred: ", err); } } } diff --git a/apps/tk/src/commands/duck.ts b/apps/tk/src/commands/duck.ts index a0251870c..217c9ded0 100644 --- a/apps/tk/src/commands/duck.ts +++ b/apps/tk/src/commands/duck.ts @@ -2,6 +2,8 @@ import type { CommandInteraction } from "discord.js"; import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; import JIMP from "jimp"; +import { logger } from "@forge/utils"; + import { TK_DUCK_URL } from "../consts"; // DUCK COMMAND @@ -42,9 +44,9 @@ export async function execute(interaction: CommandInteraction) { void interaction.reply({ embeds: [embed] }); } catch (err: unknown) { if (err instanceof Error) { - console.error(err.message); + logger.error(err.message); } else { - console.error("An unknown error occurred: ", err); + logger.error("An unknown error occurred: ", err); } } } diff --git a/apps/tk/src/commands/fact.ts b/apps/tk/src/commands/fact.ts index fc6be7d03..1504cc2b6 100644 --- a/apps/tk/src/commands/fact.ts +++ b/apps/tk/src/commands/fact.ts @@ -1,6 +1,8 @@ import type { CommandInteraction } from "discord.js"; import { SlashCommandBuilder } from "discord.js"; +import { logger } from "@forge/utils"; + import { TK_FACTS_URL } from "../consts"; // FACT COMMAND @@ -39,9 +41,9 @@ export async function execute(interaction: CommandInteraction) { return interaction.reply(data.text); } catch (err: unknown) { if (err instanceof Error) { - console.log(err.message); + logger.log(err.message); } else { - console.error("An unknown error occurred: ", err); + logger.error("An unknown error occurred: ", err); } } } diff --git a/apps/tk/src/commands/fox.ts b/apps/tk/src/commands/fox.ts index fef543cb0..802107def 100644 --- a/apps/tk/src/commands/fox.ts +++ b/apps/tk/src/commands/fox.ts @@ -2,6 +2,8 @@ import type { CommandInteraction } from "discord.js"; import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; import JIMP from "jimp"; +import { logger } from "@forge/utils"; + import { TK_FOX_URL } from "../consts"; // FOX COMMAND @@ -39,9 +41,9 @@ export async function execute(interaction: CommandInteraction) { void interaction.reply({ embeds: [embed] }); } catch (err: unknown) { if (err instanceof Error) { - console.error(err.message); + logger.error(err.message); } else { - console.error("An unknown error occurred: ", err); + logger.error("An unknown error occurred: ", err); } } } diff --git a/apps/tk/src/commands/goat.ts b/apps/tk/src/commands/goat.ts index 60402d74c..b7ba6c53c 100644 --- a/apps/tk/src/commands/goat.ts +++ b/apps/tk/src/commands/goat.ts @@ -4,12 +4,13 @@ import natural from "natural"; import sharp from "sharp"; import { db } from "@forge/db/client"; - -const { LevenshteinDistance, Metaphone } = natural; +import { logger } from "@forge/utils"; // GOAT COMMAND // random G.O.A.T. image +const { LevenshteinDistance, Metaphone } = natural; + const VALID_ONSETS = new Set([ "b", "c", @@ -169,7 +170,7 @@ export const getGoatEmbed = async () => { if (guildProfileVisible) goat = rest; } - console.log(goat); + logger.log(goat); const response = await fetch(goat.profilePictureUrl); const buffer = await response.arrayBuffer(); @@ -216,7 +217,7 @@ export async function execute(interaction: CommandInteraction) { const embed = await getGoatEmbed(); void interaction.reply({ embeds: [embed] }); } catch (err: unknown) { - if (err instanceof Error) console.error(err.message); - else console.error("An unknown error occurred: ", err); + if (err instanceof Error) logger.error(err.message); + else logger.error("An unknown error occurred: ", err); } } diff --git a/apps/tk/src/commands/joke.ts b/apps/tk/src/commands/joke.ts index 57eb8a12a..7b8a865af 100644 --- a/apps/tk/src/commands/joke.ts +++ b/apps/tk/src/commands/joke.ts @@ -1,6 +1,8 @@ import type { CommandInteraction } from "discord.js"; import { SlashCommandBuilder } from "discord.js"; +import { logger } from "@forge/utils"; + import { TK_JOKE_URL } from "../consts"; interface JokeProps { @@ -30,9 +32,9 @@ export async function execute(interaction: CommandInteraction) { } } catch (err: unknown) { if (err instanceof Error) { - console.error(err.message); + logger.error(err.message); } else { - console.error("An unknown error occurred: ", err); + logger.error("An unknown error occurred: ", err); } } } diff --git a/apps/tk/src/commands/weather.ts b/apps/tk/src/commands/weather.ts index 8d27ff7a4..21490d02b 100644 --- a/apps/tk/src/commands/weather.ts +++ b/apps/tk/src/commands/weather.ts @@ -1,6 +1,8 @@ import type { CommandInteraction } from "discord.js"; import { SlashCommandBuilder } from "discord.js"; +import { logger } from "@forge/utils"; + import type { WeatherMapKeys } from "../consts"; import { WEATHER_MAP } from "../consts"; import { env } from "../env"; @@ -83,9 +85,9 @@ export async function execute(interaction: CommandInteraction) { return interaction.reply({ embeds: [embed] }); } catch (err: unknown) { if (err instanceof Error) { - console.log(err.message); + logger.log(err.message); } else { - console.error("An unknown error occurred: ", err); + logger.error("An unknown error occurred: ", err); } } } diff --git a/apps/tk/src/deploy-commands.ts b/apps/tk/src/deploy-commands.ts index a321f81f3..f70bc85e9 100644 --- a/apps/tk/src/deploy-commands.ts +++ b/apps/tk/src/deploy-commands.ts @@ -1,5 +1,7 @@ import { REST, Routes } from "discord.js"; +import { logger } from "@forge/utils"; + import { commands } from "./commands"; import { env } from "./env"; @@ -18,7 +20,7 @@ interface DeployCommandsProps { export async function deployCommands({ guildId }: DeployCommandsProps) { try { // Log that the commands are being refreshed - console.log("Started refreshing application (/) commands."); + logger.log("Started refreshing application (/) commands."); // Load all of the commands await rest.put( @@ -29,9 +31,9 @@ export async function deployCommands({ guildId }: DeployCommandsProps) { ); // Log that the commands have been successfully reloaded - console.log("Successfully reloaded application (/) commands."); + logger.log("Successfully reloaded application (/) commands."); } catch (error) { // Log any errors that occur - console.error(error); + logger.error(error); } } diff --git a/apps/tk/src/index.ts b/apps/tk/src/index.ts index 3a0c299b8..da95bf6d7 100644 --- a/apps/tk/src/index.ts +++ b/apps/tk/src/index.ts @@ -1,5 +1,7 @@ import { Client } from "discord.js"; +import { logger } from "@forge/utils"; + import { commands } from "./commands"; import { deployCommands } from "./deploy-commands"; import { env } from "./env"; @@ -15,7 +17,7 @@ export const client = new Client({ // Log when T.K is ready client.once("ready", () => { - console.log("T.K is ready :)"); + logger.log("T.K is ready :)"); if (client.guilds.cache.size > 0) { for (const guild of client.guilds.cache.values()) { diff --git a/packages/api/eslint.config.js b/packages/api/eslint.config.js index 98642f0d6..13d70b815 100644 --- a/packages/api/eslint.config.js +++ b/packages/api/eslint.config.js @@ -7,9 +7,4 @@ export default [ }, ...baseConfig, ...restrictEnvAccess, - { - rules: { - "no-console": "off", - }, - }, ]; diff --git a/packages/api/package.json b/packages/api/package.json index 36b7c6b20..f72dd3701 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -12,6 +12,7 @@ "types": "./dist/env.d.ts", "default": "./src/env.ts" }, + "./minio/minio-client": "./src/minio/minio-client.ts", "./utils": { "types": "./dist/utils.d.ts", "default": "./src/utils.ts" @@ -32,6 +33,7 @@ "@forge/consts": "workspace:*", "@forge/db": "workspace:*", "@forge/email": "workspace:^", + "@forge/utils": "workspace:*", "@forge/validators": "workspace:*", "@stripe/stripe-js": "^5.2.0", "@trpc/server": "catalog:", diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 067a42c7c..def43c33e 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -4,9 +4,7 @@ import { z } from "zod"; export const env = createEnv({ server: { STRIPE_SECRET_KEY: z.string(), - DISCORD_BOT_TOKEN: z.string(), NODE_ENV: z.enum(["development", "production"]).optional(), - LISTMONK_FROM_EMAIL: z.string(), STRIPE_SECRET_WEBHOOK_KEY: z.string(), MINIO_ENDPOINT: z.string(), MINIO_ACCESS_KEY: z.string(), diff --git a/packages/api/src/routers/auth.ts b/packages/api/src/routers/auth.ts index 9b0dc6e34..36201c6ad 100644 --- a/packages/api/src/routers/auth.ts +++ b/packages/api/src/routers/auth.ts @@ -1,9 +1,10 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { invalidateSessionToken } from "@forge/auth/server"; +import * as discord from "@forge/utils/discord"; +import * as permissionsServer from "@forge/utils/permissions.server"; import { protectedProcedure, publicProcedure } from "../trpc"; -import { isDiscordAdmin, isDiscordMember, isJudgeAdmin } from "../utils"; export const authRouter = { getSession: publicProcedure.query(({ ctx }) => { @@ -26,16 +27,16 @@ export const authRouter = { return Promise.resolve(false); // consistent return type } - return isDiscordAdmin(ctx.session.user); + return discord.isDiscordAdmin(ctx.session.user); }), getDiscordMemberStatus: publicProcedure.query(({ ctx }): Promise => { if (!ctx.session) { return Promise.resolve(false); } - return isDiscordMember(ctx.session.user); + return discord.isDiscordMember(ctx.session.user); }), getJudgeStatus: publicProcedure.query(async () => { - const isJudge = await isJudgeAdmin(); + const isJudge = await permissionsServer.isJudgeAdmin(); return isJudge; }), diff --git a/packages/api/src/routers/csv-importer.ts b/packages/api/src/routers/csv-importer.ts index d5a7aca74..2f3a5c27a 100644 --- a/packages/api/src/routers/csv-importer.ts +++ b/packages/api/src/routers/csv-importer.ts @@ -5,9 +5,9 @@ import { FORMS } from "@forge/consts"; import { eq, sql } from "@forge/db"; import { db } from "@forge/db/client"; import { Challenges, Submissions, Teams } from "@forge/db/schemas/knight-hacks"; +import { logger, permissions } from "@forge/utils"; import { permProcedure } from "../trpc"; -import { controlPerms } from "../utils"; interface CsvImporterRecord { "Opt-In Prize": string | null; @@ -32,7 +32,7 @@ export const csvImporterRouter = { }), ) .mutation(async ({ ctx, input }) => { - controlPerms.or(["IS_OFFICER"], ctx); + permissions.controlPerms.or(["IS_OFFICER"], ctx); try { // Get raw records @@ -249,7 +249,7 @@ export const csvImporterRouter = { ([matchKey, teamRows]) => { const teamId = teamIdMap.get(matchKey); if (!teamId) { - console.error(`Team not found for matchKey: ${matchKey}`); + logger.error(`Team not found for matchKey: ${matchKey}`); throw new Error(`Failed to find team ID for: ${matchKey}`); } @@ -312,7 +312,7 @@ export const csvImporterRouter = { return result; } catch (error) { - console.error("CSV import error:", error); + logger.error("CSV import error:", error); throw new Error( error instanceof Error ? error.message : "Failed to import CSV", diff --git a/packages/api/src/routers/dues-payment.ts b/packages/api/src/routers/dues-payment.ts index 5d60e4075..0db13fabc 100644 --- a/packages/api/src/routers/dues-payment.ts +++ b/packages/api/src/routers/dues-payment.ts @@ -7,10 +7,12 @@ import { CLUB } from "@forge/consts"; import { eq } from "@forge/db"; import { db } from "@forge/db/client"; import { DuesPayment, Member } from "@forge/db/schemas/knight-hacks"; +import { permissions } from "@forge/utils"; +import * as discord from "@forge/utils/discord"; +import { stripe } from "@forge/utils/stripe"; import { env } from "../env"; import { permProcedure, protectedProcedure } from "../trpc"; -import { controlPerms, log, stripe } from "../utils"; export const duesPaymentRouter = { createCheckout: protectedProcedure.mutation(async ({ ctx }) => { @@ -80,7 +82,7 @@ export const duesPaymentRouter = { const stripe = new Stripe(env.STRIPE_SECRET_KEY, { typescript: true }); const session = await stripe.checkout.sessions.retrieve(input); - await log({ + await discord.log({ message: `A member has successfully paid their dues. ${session.amount_total}`, title: "Dues Paid", color: "success_green", @@ -96,7 +98,7 @@ export const duesPaymentRouter = { }), getDuesPaymentDates: permProcedure.query(async ({ ctx }) => { - controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); + permissions.controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); return await db .select({ paymentDate: DuesPayment.paymentDate }) diff --git a/packages/api/src/routers/email.ts b/packages/api/src/routers/email.ts index c5feaf12c..480ded29a 100644 --- a/packages/api/src/routers/email.ts +++ b/packages/api/src/routers/email.ts @@ -1,8 +1,10 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { z } from "zod"; +import { sendEmail } from "@forge/email"; +import { logger, permissions } from "@forge/utils"; + import { permProcedure } from "../trpc"; -import { controlPerms, sendEmail } from "../utils"; export const emailRouter = { sendEmail: permProcedure @@ -16,8 +18,8 @@ export const emailRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EMAIL_PORTAL"], ctx); - console.log(input.data); + permissions.controlPerms.or(["EMAIL_PORTAL"], ctx); + logger.log(input.data); try { const response = await sendEmail({ to: input.to, @@ -29,7 +31,7 @@ export const emailRouter = { return response; } catch (error) { - console.error("Error sending email:", { + logger.error("Error sending email:", { error: error instanceof Error ? error.message : error, input, }); diff --git a/packages/api/src/routers/event-feedback.ts b/packages/api/src/routers/event-feedback.ts index 71e10a8fc..aaa5ebaa1 100644 --- a/packages/api/src/routers/event-feedback.ts +++ b/packages/api/src/routers/event-feedback.ts @@ -2,9 +2,9 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { z } from "zod"; import { DISCORD } from "@forge/consts"; +import * as discord from "@forge/utils/discord"; import { permProcedure } from "../trpc"; -import { log } from "../utils"; export const eventFeedbackRouter = { logHackathonFeedback: permProcedure @@ -14,7 +14,7 @@ export const eventFeedbackRouter = { }), ) .mutation(async ({ input, ctx }) => { - await log({ + await discord.log({ message: `<@&${DISCORD.OFFICER_ROLE}> ${input.description}`, title: "Hackathon Issue", color: "uhoh_red", diff --git a/packages/api/src/routers/event.ts b/packages/api/src/routers/event.ts index 07f58b3ca..839cdb584 100644 --- a/packages/api/src/routers/event.ts +++ b/packages/api/src/routers/event.ts @@ -29,9 +29,12 @@ import { InsertEventSchema, Member, } from "@forge/db/schemas/knight-hacks"; +import { logger, permissions } from "@forge/utils"; +import * as discord from "@forge/utils/discord"; +import * as forms from "@forge/utils/forms"; +import * as google from "@forge/utils/google"; import { permProcedure, protectedProcedure, publicProcedure } from "../trpc"; -import { calendar, controlPerms, createForm, discord, log } from "../utils"; export const eventRouter = { getEvents: publicProcedure.query(async () => { @@ -126,7 +129,7 @@ export const eventRouter = { getAttendees: permProcedure .input(z.string()) .query(async ({ ctx, input }) => { - controlPerms.or(["READ_CLUB_EVENT"], ctx); + permissions.controlPerms.or(["READ_CLUB_EVENT"], ctx); const attendees = await db .select({ @@ -142,7 +145,7 @@ export const eventRouter = { getHackerAttendees: permProcedure .input(z.string()) .query(async ({ ctx, input }) => { - controlPerms.or(["READ_HACK_EVENT"], ctx); + permissions.controlPerms.or(["READ_HACK_EVENT"], ctx); const attendees = await db .select({ @@ -167,7 +170,7 @@ export const eventRouter = { InsertEventSchema.omit({ id: true, discordId: true, googleId: true }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_CLUB_EVENT", "EDIT_HACK_EVENT"], ctx); + permissions.controlPerms.or(["EDIT_CLUB_EVENT", "EDIT_HACK_EVENT"], ctx); // Step 0: Convert provided start/end datetimes into Local Date objects const startDatetime = new Date(input.start_datetime); @@ -205,7 +208,7 @@ export const eventRouter = { // Step 1: Create the event in Discord let discordEventId: string | undefined; try { - const response = (await discord.post( + const response = (await discord.api.post( Routes.guildScheduledEvents(DISCORD.KNIGHTHACKS_GUILD), { body: { @@ -223,7 +226,7 @@ export const eventRouter = { )) as APIExternalGuildScheduledEvent; discordEventId = response.id; } catch (error) { - console.error(JSON.stringify(error, null, 2)); + logger.error(JSON.stringify(error, null, 2)); throw new TRPCError({ message: "Failed to create event in Discord", code: "BAD_REQUEST", @@ -233,7 +236,7 @@ export const eventRouter = { // Step 2: Insert the event into the Google Calendar let googleEventId: string | undefined; try { - const response = await calendar.events.insert({ + const response = await google.calendar.events.insert({ calendarId: EVENTS.GOOGLE_CALENDAR_ID, requestBody: { end: { @@ -251,19 +254,19 @@ export const eventRouter = { } as calendar_v3.Params$Resource$Events$Insert); googleEventId = response.data.id ?? undefined; } catch (error) { - console.error("ERROR MESSAGE:", JSON.stringify(error, null, 2)); + logger.error("ERROR MESSAGE:", JSON.stringify(error, null, 2)); // Clean up the event in Discord if the Google Calendar event fails if (discordEventId) { try { - await discord.delete( + await discord.api.delete( Routes.guildScheduledEvent( DISCORD.KNIGHTHACKS_GUILD, discordEventId, ), ); } catch (cleanupErr) { - console.error(JSON.stringify(cleanupErr, null, 2)); + logger.error(JSON.stringify(cleanupErr, null, 2)); } } @@ -303,28 +306,28 @@ export const eventRouter = { googleId: googleEventId, }); } catch (error) { - console.error(JSON.stringify(error, null, 2)); + logger.error(JSON.stringify(error, null, 2)); // Clean up the event in Discord if the database insert fails try { - await discord.delete( + await discord.api.delete( Routes.guildScheduledEvent( DISCORD.KNIGHTHACKS_GUILD, discordEventId, ), ); } catch (cleanupErr) { - console.error(JSON.stringify(cleanupErr, null, 2)); + logger.error(JSON.stringify(cleanupErr, null, 2)); } // Clean up the event in Google Calendar if the database insert fails try { - await calendar.events.delete({ + await google.calendar.events.delete({ calendarId: EVENTS.GOOGLE_CALENDAR_ID, eventId: googleEventId, }); } catch (cleanupErr) { - console.error(JSON.stringify(cleanupErr, null, 2)); + logger.error(JSON.stringify(cleanupErr, null, 2)); } throw new TRPCError({ @@ -334,7 +337,7 @@ export const eventRouter = { } // Step 4: Log the creation - await log({ + await discord.log({ title: "Event Created", message: `The event **${formattedName}** was created.`, color: "blade_purple", @@ -345,7 +348,7 @@ export const eventRouter = { updateEvent: permProcedure .input(InsertEventSchema) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_CLUB_EVENT", "EDIT_HACK_EVENT"], ctx); + permissions.controlPerms.or(["EDIT_CLUB_EVENT", "EDIT_HACK_EVENT"], ctx); if (!input.id) { throw new TRPCError({ @@ -399,7 +402,7 @@ export const eventRouter = { // Step 1: Update the event in Discord try { - await discord.patch( + await discord.api.patch( Routes.guildScheduledEvent( DISCORD.KNIGHTHACKS_GUILD, input.discordId, @@ -419,7 +422,7 @@ export const eventRouter = { }, ); } catch (error) { - console.error(JSON.stringify(error, null, 2)); + logger.error(JSON.stringify(error, null, 2)); throw new TRPCError({ message: "Failed to update event in Discord", code: "BAD_REQUEST", @@ -428,7 +431,7 @@ export const eventRouter = { // Step 2: Update the event in Google Calendar try { - await calendar.events.update({ + await google.calendar.events.update({ calendarId: EVENTS.GOOGLE_CALENDAR_ID, eventId: input.googleId, requestBody: { @@ -446,7 +449,7 @@ export const eventRouter = { }, } as calendar_v3.Params$Resource$Events$Update); } catch (error) { - console.error(JSON.stringify(error, null, 2)); + logger.error(JSON.stringify(error, null, 2)); throw new TRPCError({ message: "Failed to update event in Google Calendar", code: "BAD_REQUEST", @@ -518,7 +521,7 @@ export const eventRouter = { const oldFormattedName = `[${event.tag.toUpperCase().replace(" ", "-")}] ${event.name}`; - await log({ + await discord.log({ title: "Event Updated", message: `Event **${oldFormattedName}** was updated.\n**Changes:**\n${changesString}`, color: "blade_purple", @@ -552,7 +555,7 @@ export const eventRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_CLUB_EVENT", "EDIT_HACK_EVENT"], ctx); + permissions.controlPerms.or(["EDIT_CLUB_EVENT", "EDIT_HACK_EVENT"], ctx); if (!input.id) { throw new TRPCError({ @@ -563,14 +566,14 @@ export const eventRouter = { // Step 1: Delete the event in Discord try { - await discord.delete( + await discord.api.delete( Routes.guildScheduledEvent( DISCORD.KNIGHTHACKS_GUILD, input.discordId, ), ); } catch (error) { - console.error(JSON.stringify(error, null, 2)); + logger.error(JSON.stringify(error, null, 2)); throw new TRPCError({ message: "Failed to delete event in Discord", code: "BAD_REQUEST", @@ -579,12 +582,12 @@ export const eventRouter = { // Step 2: Delete the event in the Google Calendar try { - await calendar.events.delete({ + await google.calendar.events.delete({ calendarId: EVENTS.GOOGLE_CALENDAR_ID, eventId: input.googleId, } as calendar_v3.Params$Resource$Events$Delete); } catch (error) { - console.error(JSON.stringify(error, null, 2)); + logger.error(JSON.stringify(error, null, 2)); throw new TRPCError({ message: "Failed to delete event in Google Calendar", code: "BAD_REQUEST", @@ -592,7 +595,7 @@ export const eventRouter = { } const formattedName = `[${input.tag.toUpperCase().replace(" ", "-")}] ${input.name}`; - await log({ + await discord.log({ title: "Event Deleted", message: `The event **${formattedName}** was deleted.`, color: "uhoh_red", @@ -630,7 +633,7 @@ export const eventRouter = { if (form) return form; try { - return await createForm({ + return await forms.createForm({ formData: { name: formName, description: `Provide feedback for ${event.name} to help us make events better in the future!`, @@ -699,7 +702,7 @@ export const eventRouter = { }), ) .query(async ({ ctx, input }) => { - controlPerms.or(["READ_CLUB_EVENT"], ctx); + permissions.controlPerms.or(["READ_CLUB_EVENT"], ctx); const conditions = []; diff --git a/packages/api/src/routers/forms.ts b/packages/api/src/routers/forms.ts index 4298d3f3f..b19f2fcf9 100644 --- a/packages/api/src/routers/forms.ts +++ b/packages/api/src/routers/forms.ts @@ -20,24 +20,19 @@ import { TrpcFormConnection, TrpcFormConnectionSchema, } from "@forge/db/schemas/knight-hacks"; +import { logger, permissions } from "@forge/utils"; +import * as discord from "@forge/utils/discord"; +import * as forms from "@forge/utils/forms"; import { minioClient } from "../minio/minio-client"; import { permProcedure, protectedProcedure } from "../trpc"; -import { - controlPerms, - createForm, - CreateFormSchema, - generateJsonSchema, - log, - regenerateMediaUrls, -} from "../utils"; export const formsRouter = { createForm: permProcedure - .input(CreateFormSchema) + .input(forms.CreateFormSchema) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); - await createForm(input); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); + await forms.createForm(input); }), updateForm: permProcedure @@ -53,8 +48,8 @@ export const formsRouter = { .extend({ responseRoleIds: z.array(z.string().uuid()).optional() }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); - const jsonSchema = generateJsonSchema(input.formData); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); + const jsonSchema = forms.generateJsonSchema(input.formData); const slug_name = input.formData.name.toLowerCase().replaceAll(" ", "-"); @@ -154,8 +149,9 @@ export const formsRouter = { .where(eq(FormResponseRoles.formId, form.id)); // Regenerate presigned URLs for any media that has objectNames - const instructionsWithFreshUrls = await regenerateMediaUrls( + const instructionsWithFreshUrls = await forms.regenerateMediaUrls( formData.instructions, + minioClient, ); return { @@ -201,7 +197,7 @@ export const formsRouter = { deleteForm: permProcedure .input(z.object({ slug_name: z.string() })) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); // find the form to delete duh const form = await db.query.FormsSchemas.findFirst({ where: (t, { eq }) => @@ -234,7 +230,7 @@ export const formsRouter = { }), ) .query(async ({ input, ctx }) => { - controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); + permissions.controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); const { cursor, section } = input; const limit = input.limit; @@ -285,7 +281,7 @@ export const formsRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); const form = await db.query.FormsSchemas.findFirst({ where: (t, { eq }) => eq(t.id, input.form), @@ -327,7 +323,7 @@ export const formsRouter = { deleteConnection: permProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); try { await db .delete(TrpcFormConnection) @@ -406,7 +402,7 @@ export const formsRouter = { } const formData = form.formData as FORMS.FormType; - const jsonSchema = generateJsonSchema(formData); + const jsonSchema = forms.generateJsonSchema(formData); if (!jsonSchema.success) { throw new TRPCError({ @@ -448,7 +444,7 @@ export const formsRouter = { ...input, }); - await log({ + await discord.log({ title: `Form submitted to blade forms`, message: `**Form submitted:** ${form.name}\n**User:** ${ctx.session.user.name}`, color: "success_green", @@ -499,7 +495,7 @@ export const formsRouter = { // Validate responseData against form schema const formData = form.formData as FORMS.FormType; - const jsonSchema = generateJsonSchema(formData); + const jsonSchema = forms.generateJsonSchema(formData); if (!jsonSchema.success) { throw new TRPCError({ @@ -538,7 +534,7 @@ export const formsRouter = { getResponses: permProcedure .input(z.object({ form: z.string() })) .query(async ({ input, ctx }) => { - controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); + permissions.controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); return await db .select({ id: FormResponse.id, @@ -560,10 +556,10 @@ export const formsRouter = { deleteResponse: permProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); try { await db.delete(FormResponse).where(eq(FormResponse.id, input.id)); - await log({ + await discord.log({ title: `Form response deleted`, message: `**Response deleted:** ${input.id}`, color: "uhoh_red", @@ -711,7 +707,7 @@ export const formsRouter = { return { uploadUrl, objectName, viewUrl }; } catch (e) { - console.error("getUploadUrl error:", e); + logger.error("getUploadUrl error:", e); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to generate upload URL", @@ -726,7 +722,7 @@ export const formsRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); const { objectName } = input; try { @@ -736,7 +732,7 @@ export const formsRouter = { ); return { success: true }; } catch (e) { - console.error("deleteMedia error:", e); + logger.error("deleteMedia error:", e); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to delete media", @@ -751,7 +747,7 @@ export const formsRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); + permissions.controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); const { objectName } = input; try { @@ -762,7 +758,7 @@ export const formsRouter = { ); return { viewUrl }; } catch (e) { - console.error("getFileUrl error:", e); + logger.error("getFileUrl error:", e); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to generate file URL", @@ -771,7 +767,7 @@ export const formsRouter = { }), getSections: permProcedure.query(async ({ ctx }) => { - controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); + permissions.controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); const isOfficer = ctx.session.permissions.IS_OFFICER; @@ -890,7 +886,7 @@ export const formsRouter = { }), getSectionCounts: permProcedure.query(async ({ ctx }) => { - controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); + permissions.controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); const counts = await db .select({ section: FormsSchemas.section, @@ -913,7 +909,7 @@ export const formsRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); const form = await db.query.FormsSchemas.findFirst({ where: (t, { eq }) => eq(t.slugName, decodeURIComponent(input.slug_name)), @@ -941,7 +937,7 @@ export const formsRouter = { .set({ section: input.section, sectionId }) .where(eq(FormsSchemas.id, form.id)); - await log({ + await discord.log({ title: `Form section updated`, message: `**Form:** ${form.name}\n**Section:** ${oldSection} -> ${input.section}`, color: "success_green", @@ -957,7 +953,7 @@ export const formsRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); await db .update(FormSections) @@ -969,7 +965,7 @@ export const formsRouter = { .set({ section: input.newName }) .where(eq(FormsSchemas.section, input.oldName)); - await log({ + await discord.log({ title: `Form section renamed`, message: `**Form section:** ${input.oldName} -> ${input.newName}`, color: "success_green", @@ -980,7 +976,7 @@ export const formsRouter = { deleteSection: permProcedure .input(z.object({ section: z.string() })) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); await db .update(FormsSchemas) .set({ section: "General", sectionId: null }) @@ -999,7 +995,7 @@ export const formsRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); const existing = await db.query.FormSections.findFirst({ where: (t, { eq }) => eq(t.name, input.name), @@ -1074,7 +1070,7 @@ export const formsRouter = { .from(Roles) .where(inArray(Roles.id, input.roleIds)); - await log({ + await discord.log({ title: `Form section created`, message: `**Form section:** ${input.name}. Roles: ${roleNames.map((r) => r.name).join(", ")}`, color: "success_green", @@ -1085,7 +1081,7 @@ export const formsRouter = { getSectionRoles: permProcedure .input(z.object({ sectionName: z.string() })) .query(async ({ input, ctx }) => { - controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); + permissions.controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); const section = await db.query.FormSections.findFirst({ where: (t, { eq }) => eq(t.name, input.sectionName), @@ -1124,7 +1120,7 @@ export const formsRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); const section = await db.query.FormSections.findFirst({ where: (t, { eq }) => eq(t.name, input.sectionName), @@ -1180,7 +1176,7 @@ export const formsRouter = { .from(Roles) .where(inArray(Roles.id, input.roleIds)); - await log({ + await discord.log({ title: `Form section roles updated`, message: `**Form section:** ${input.sectionName}. Roles: ${roleNames.length > 0 ? roleNames.map((r) => r.name).join(", ") : "None (all users)"}`, color: "success_green", @@ -1196,7 +1192,7 @@ export const formsRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); const allSections = await db .select({ @@ -1238,7 +1234,7 @@ export const formsRouter = { .set({ order: currentSection?.order ?? currentIndex }) .where(eq(FormSections.id, targetSection?.id ?? "")); - await log({ + await discord.log({ title: `Form section reordered`, message: `**Form section:** ${input.sectionName} moved ${input.direction}`, color: "success_green", @@ -1249,7 +1245,7 @@ export const formsRouter = { checkFormEditAccess: permProcedure .input(z.object({ slug_name: z.string() })) .query(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); const isOfficer = ctx.session.permissions.IS_OFFICER; @@ -1336,7 +1332,7 @@ export const formsRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + permissions.controlPerms.or(["EDIT_FORMS"], ctx); // Get the form const form = await db.query.FormsSchemas.findFirst({ @@ -1363,7 +1359,7 @@ export const formsRouter = { throw new TRPCError({ message: "Form not found", code: "NOT_FOUND" }); } - await log({ + await discord.log({ title: `Form ${updatedForm.isClosed ? "closed" : "opened"}`, message: `**Form:** ${updatedForm.name}`, color: updatedForm.isClosed ? "uhoh_red" : "success_green", diff --git a/packages/api/src/routers/guild.ts b/packages/api/src/routers/guild.ts index d41a10d30..de43eb782 100644 --- a/packages/api/src/routers/guild.ts +++ b/packages/api/src/routers/guild.ts @@ -8,6 +8,7 @@ import { MINIO } from "@forge/consts"; import { and, count, sql } from "@forge/db"; import { db } from "@forge/db/client"; import { Member } from "@forge/db/schemas/knight-hacks"; +import { logger } from "@forge/utils"; import { env } from "../env"; import { minioClient } from "../minio/minio-client"; @@ -42,7 +43,7 @@ export const guildRouter = { ); if (!base64Data) { - console.error("uploadProfilePicture: Base64 data is missing."); + logger.error("uploadProfilePicture: Base64 data is missing."); throw new TRPCError({ code: "BAD_REQUEST", message: "Base64 data is missing or invalid after stripping prefix.", @@ -65,7 +66,7 @@ export const guildRouter = { ); } } catch (e) { - console.error( + logger.error( "uploadProfilePicture: Error checking/creating bucket:", e, ); @@ -88,7 +89,7 @@ export const guildRouter = { } } } catch (e) { - console.warn( + logger.warn( "uploadProfilePicture: Error listing existing profile pictures, proceeding with upload:", e, ); @@ -101,7 +102,7 @@ export const guildRouter = { existingObjects, ); } catch (e) { - console.error( + logger.error( "uploadProfilePicture: Error removing existing profile pictures:", e, ); @@ -121,7 +122,7 @@ export const guildRouter = { { "Content-Type": contentType }, ); } catch (e) { - console.error( + logger.error( "uploadProfilePicture: Error uploading profile picture to Minio:", e, ); @@ -247,7 +248,7 @@ export const guildRouter = { "response-content-disposition": `attachment; filename="${downloadName}"`, }, ); - console.log("Resumé URL generated:", url); + logger.log("Resumé URL generated:", url); return { url }; } catch { throw new TRPCError({ diff --git a/packages/api/src/routers/hackers/mutations.ts b/packages/api/src/routers/hackers/mutations.ts index fdab3085d..38b54597c 100644 --- a/packages/api/src/routers/hackers/mutations.ts +++ b/packages/api/src/routers/hackers/mutations.ts @@ -16,16 +16,11 @@ import { HackerEventAttendee, InsertHackerSchema, } from "@forge/db/schemas/knight-hacks"; +import { logger, permissions } from "@forge/utils"; +import * as discord from "@forge/utils/discord"; import { minioClient } from "../../minio/minio-client"; import { permProcedure, protectedProcedure } from "../../trpc"; -import { - addRoleToMember, - controlPerms, - isDiscordVIP, - log, - resolveDiscordUserId, -} from "../../utils"; export const hackerMutationRouter = { createHacker: protectedProcedure @@ -99,7 +94,7 @@ export const hackerMutationRouter = { ); } } catch (error) { - console.error("Error with generating QR code: ", error); + logger.error("Error with generating QR code: ", error); } const today = new Date(); @@ -131,7 +126,7 @@ export const hackerMutationRouter = { status: "pending", }); - await log({ + await discord.log({ title: `Hacker Created for ${hackathon.displayName}`, message: `${hackerData.firstName} ${hackerData.lastName} has signed up for the upcoming hackathon: ${hackathon.name.toUpperCase()}!`, color: "tk_blue", @@ -230,7 +225,7 @@ export const hackerMutationRouter = { .join("\n"); // Log the changes - await log({ + await discord.log({ title: "Hacker Updated", message: `Blade profile for ${hacker.firstName} ${hacker.lastName} has been updated. \n**Changes:**\n${changesString}`, @@ -250,7 +245,7 @@ export const hackerMutationRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_HACKERS"], ctx); + permissions.controlPerms.or(["EDIT_HACKERS"], ctx); if (!input.id) { throw new TRPCError({ @@ -261,7 +256,7 @@ export const hackerMutationRouter = { await db.delete(Hacker).where(eq(Hacker.id, input.id)); - await log({ + await discord.log({ title: `Hacker Deleted for ${input.hackathonName}`, message: `Profile for ${input.firstName} ${input.lastName} has been deleted.`, color: "uhoh_red", @@ -354,7 +349,7 @@ export const hackerMutationRouter = { ), ); - await log({ + await discord.log({ title: "Hacker Confirmed", message: `${hacker.firstName} ${hacker.lastName} has confirmed their attendance!`, color: "success_green", @@ -463,7 +458,7 @@ export const hackerMutationRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["CHECKIN_HACK_EVENT", "EDIT_HACKERS"], ctx); + permissions.controlPerms.or(["CHECKIN_HACK_EVENT", "EDIT_HACKERS"], ctx); const event = await db.query.Event.findFirst({ where: eq(Event.id, input.eventId), @@ -525,8 +520,8 @@ export const hackerMutationRouter = { const eventTag = event.tag; let discordId: string | null = null; - discordId = await resolveDiscordUserId(hacker.discordUser); - const isVIP = discordId ? await isDiscordVIP(discordId) : false; + discordId = await discord.resolveDiscordUserId(hacker.discordUser); + const isVIP = discordId ? await discord.isDiscordVIP(discordId) : false; let assignedClass: HackerClass | null = hackerAttendee.class ?? null; @@ -594,7 +589,7 @@ export const hackerMutationRouter = { }); if (!discordId) { - await log({ + await discord.log({ title: "Discord role assign skipped", message: `Could not resolve Discord ID for "${hacker.discordUser}".`, color: "uhoh_red", @@ -602,16 +597,16 @@ export const hackerMutationRouter = { }); } else { try { - await addRoleToMember( + await discord.addRoleToMember( discordId, HACKATHONS.KNIGHT_HACKS_8.KH_EVENT_ROLE_ID, ); - console.log( + logger.log( `Assigned role ${HACKATHONS.KNIGHT_HACKS_8.KH_EVENT_ROLE_ID} to user ${discordId}`, ); // VIP will already be given the discord role ahead of time, so no need to assign again if (assignedClass) { - await addRoleToMember( + await discord.addRoleToMember( discordId, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition HACKATHONS.KNIGHT_HACKS_8.CLASS_ROLE_ID[ @@ -620,13 +615,13 @@ export const hackerMutationRouter = { ); } } catch (e) { - await log({ + await discord.log({ title: "Discord role assign failed", message: `Failed to assign Discord roles for "${hacker.discordUser}".`, color: "uhoh_red", userId: ctx.session.user.discordUserId, }); - console.error( + logger.error( "Failed to assign Discord roles:", (e as Error).message, ); @@ -679,7 +674,7 @@ export const hackerMutationRouter = { .where(eq(HackerAttendee.id, hackerAttendee.id)); if (eventTag === "Check-in") { - await log({ + await discord.log({ title: `Hacker Checked-In`, message: `${hacker.firstName} ${hacker.lastName} has been checked in to Hackathon ${ assignedClass ? ` (Class: ${assignedClass}).` : "" @@ -698,7 +693,7 @@ export const hackerMutationRouter = { eventName: eventTag, }; } - await log({ + await discord.log({ title: "Hacker Checked-In", message: `Hacker ${hacker.firstName} ${hacker.lastName} has been checked in to event ${eventTag}.`, color: "success_green", @@ -722,7 +717,7 @@ export const hackerMutationRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_HACKERS"], ctx); + permissions.controlPerms.or(["EDIT_HACKERS"], ctx); if (!input.id) { throw new TRPCError({ @@ -776,7 +771,7 @@ export const hackerMutationRouter = { ), ); - await log({ + await discord.log({ title: `Gave Points`, message: `Gave ${input.amount} points to ${hacker.firstName} ${hacker.lastName} for ${hackathon.displayName}`, color: "tk_blue", @@ -800,7 +795,7 @@ export const hackerMutationRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_HACKERS"], ctx); + permissions.controlPerms.or(["EDIT_HACKERS"], ctx); if (!input.id) { throw new TRPCError({ @@ -843,7 +838,7 @@ export const hackerMutationRouter = { ), ); - await log({ + await discord.log({ title: `Hacker Status Updated ${hackathon.displayName ? `for ${hackathon.displayName}` : ""}`, message: `Hacker status for ${hacker.firstName} ${hacker.lastName} has changed to ${input.status}!`, color: "tk_blue", diff --git a/packages/api/src/routers/hackers/pagination.ts b/packages/api/src/routers/hackers/pagination.ts index 2d215587a..5eb406dd3 100644 --- a/packages/api/src/routers/hackers/pagination.ts +++ b/packages/api/src/routers/hackers/pagination.ts @@ -3,9 +3,9 @@ import { z } from "zod"; import { and, asc, count, desc, eq, ilike, ne, or, sql } from "@forge/db"; import { db } from "@forge/db/client"; import { Hacker, HackerAttendee } from "@forge/db/schemas/knight-hacks"; +import { permissions } from "@forge/utils"; import { permProcedure } from "../../trpc"; -import { controlPerms } from "../../utils"; const SOFT_BLACKLIST_HACKER_ID = "7f89fe4d-26f0-42fe-ac98-22d8f648d7a7"; @@ -47,7 +47,7 @@ export const hackerPaginationRouter = { }), ) .query(async ({ ctx, input }) => { - controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx); + permissions.controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx); const currentPage = input.currentPage ?? 1; const pageSize = input.pageSize ?? 10; @@ -216,7 +216,7 @@ export const hackerPaginationRouter = { }), ) .query(async ({ ctx, input }) => { - controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx); + permissions.controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx); const conditions = [eq(HackerAttendee.hackathonId, input.hackathonId)]; @@ -309,7 +309,7 @@ export const hackerPaginationRouter = { }), ) .query(async ({ ctx, input }) => { - controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx); + permissions.controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx); const gradYearExpr = sql`EXTRACT(YEAR FROM ${Hacker.gradDate})::int`; const isFirstTimeExpr = sql`COALESCE(${Hacker.isFirstTime}, false)`; const whereClause = eq(HackerAttendee.hackathonId, input.hackathonId); diff --git a/packages/api/src/routers/hackers/queries.ts b/packages/api/src/routers/hackers/queries.ts index 4eac4b432..13bdc164f 100644 --- a/packages/api/src/routers/hackers/queries.ts +++ b/packages/api/src/routers/hackers/queries.ts @@ -9,9 +9,9 @@ import { HACKER_CLASSES, HackerAttendee, } from "@forge/db/schemas/knight-hacks"; +import { permissions } from "@forge/utils"; import { permProcedure, protectedProcedure } from "../../trpc"; -import { controlPerms } from "../../utils"; export const hackerQueryRouter = { getHacker: protectedProcedure @@ -108,7 +108,7 @@ export const hackerQueryRouter = { getHackers: permProcedure.input(z.string()).query(async ({ ctx, input }) => { // CHECKIN_HACK_EVENT is here because people trying to check-in // need to retrieve the member list for manual entry - controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx); + permissions.controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx); const hackers = await db .select({ @@ -157,7 +157,7 @@ export const hackerQueryRouter = { getAllHackers: permProcedure .input(z.object({ hackathonName: z.string().optional() })) .query(async ({ ctx, input }) => { - controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx); + permissions.controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx); let hackathon; @@ -420,7 +420,7 @@ export const hackerQueryRouter = { statusCountByHackathonId: permProcedure .input(z.string()) .query(async ({ ctx, input: hackathonId }) => { - controlPerms.or(["READ_HACK_DATA"], ctx); + permissions.controlPerms.or(["READ_HACK_DATA"], ctx); const results = await Promise.all( FORMS.HACKATHON_APPLICATION_STATES.map(async (s) => { diff --git a/packages/api/src/routers/judge.ts b/packages/api/src/routers/judge.ts index 9119e8d10..163e010bc 100644 --- a/packages/api/src/routers/judge.ts +++ b/packages/api/src/routers/judge.ts @@ -15,10 +15,10 @@ import { Submissions, Teams, } from "@forge/db/schemas/knight-hacks"; +import { permissions } from "@forge/utils"; import { env } from "../env"; import { judgeProcedure, permProcedure, publicProcedure } from "../trpc"; -import { controlPerms } from "../utils"; const SESSION_TTL_HOURS = 8; @@ -556,7 +556,7 @@ export const judgeRouter = { // Admin: Get all unique rooms with session counts getRoomsWithSessionCounts: permProcedure.query(async ({ ctx }) => { - controlPerms.or(["IS_OFFICER"], ctx); + permissions.controlPerms.or(["IS_OFFICER"], ctx); const now = new Date(); const rooms = await db @@ -576,7 +576,7 @@ export const judgeRouter = { deleteSessionsByRoom: permProcedure .input(z.object({ roomName: z.string() })) .mutation(async ({ ctx, input }) => { - controlPerms.or(["IS_OFFICER"], ctx); + permissions.controlPerms.or(["IS_OFFICER"], ctx); const result = await db .delete(JudgeSession) diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts index eacd2344a..05bbb58b3 100644 --- a/packages/api/src/routers/member.ts +++ b/packages/api/src/routers/member.ts @@ -28,10 +28,11 @@ import { Member, OtherCompanies, } from "@forge/db/schemas/knight-hacks"; +import { logger, permissions } from "@forge/utils"; +import * as discord from "@forge/utils/discord"; import { minioClient } from "../minio/minio-client"; import { permProcedure, protectedProcedure } from "../trpc"; -import { controlPerms, log } from "../utils"; export const memberRouter = { createMember: protectedProcedure @@ -76,7 +77,7 @@ export const memberRouter = { ); } } catch (error) { - console.error("Error with generating QR code: ", error); + logger.error("Error with generating QR code: ", error); } const today = new Date(); @@ -100,7 +101,7 @@ export const memberRouter = { name: company, }); } catch (error) { - console.log("Unable to insert company: ", error); + logger.log("Unable to insert company: ", error); } } @@ -112,7 +113,7 @@ export const memberRouter = { phoneNumber: input.phoneNumber === "" ? null : input.phoneNumber, }); - await log({ + await discord.log({ title: "Member Created", message: `${input.firstName} ${input.lastName} has signed up for Blade`, color: "tk_blue", @@ -194,7 +195,7 @@ export const memberRouter = { name: company, }); } catch (error) { - console.log("Unable to insert company: ", error); + logger.log("Unable to insert company: ", error); } } @@ -258,7 +259,7 @@ export const memberRouter = { .join("\n"); // Log the changes - await log({ + await discord.log({ title: "Member Updated", message: `Blade profile for ${member.firstName} ${member.lastName} has been updated. \n**Changes:**\n${changesString}`, @@ -281,7 +282,7 @@ export const memberRouter = { }); } await db.delete(Member).where(eq(Member.id, input.id)); - await log({ + await discord.log({ title: "Member Deleted", message: `Profile for ${memberToDelete.firstName} ${memberToDelete.lastName} (ID: ${input.id}) has been deleted.`, color: "uhoh_red", @@ -357,7 +358,7 @@ export const memberRouter = { .optional(), ) .query(async ({ input, ctx }) => { - controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); + permissions.controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); // If theres a fetch all flag set to true OR no inputs are passed in get all members if ( @@ -454,7 +455,7 @@ export const memberRouter = { .optional(), ) .query(async ({ input, ctx }) => { - controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); + permissions.controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); const conditions = []; @@ -500,7 +501,7 @@ export const memberRouter = { }), getDistinctSchools: permProcedure.query(async ({ ctx }) => { - controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); + permissions.controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); const results = await db .selectDistinct({ school: Member.school }) .from(Member) @@ -511,7 +512,7 @@ export const memberRouter = { }), getDistinctMajors: permProcedure.query(async ({ ctx }) => { - controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); + permissions.controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); const results = await db .selectDistinct({ major: Member.major }) .from(Member) @@ -521,7 +522,7 @@ export const memberRouter = { }), getMemberFilterOptions: permProcedure.query(async ({ ctx }) => { - controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); + permissions.controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); const rows = await db .select({ @@ -556,7 +557,7 @@ export const memberRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_MEMBERS"], ctx); + permissions.controlPerms.or(["EDIT_MEMBERS"], ctx); const member = await db.query.Member.findFirst({ where: eq(Member.id, input.id), @@ -574,7 +575,7 @@ export const memberRouter = { .set({ points: sql`${Member.points} + ${input.amount}` }) .where(eq(Member.id, member.id)); - await log({ + await discord.log({ title: `Gave Points`, message: `Gave ${input.amount} points to ${member.firstName} ${member.lastName} (Member)`, color: "tk_blue", @@ -583,7 +584,7 @@ export const memberRouter = { }), getDuesPayingMembers: permProcedure.query(async ({ ctx }) => { - controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); + permissions.controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); return await db .select() @@ -599,7 +600,7 @@ export const memberRouter = { }), getMemberAttendanceCounts: permProcedure.query(async ({ ctx }) => { - controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); + permissions.controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx); // Get attendance count for each member const memberAttendance = await db @@ -627,7 +628,7 @@ export const memberRouter = { createDuesPayingMember: permProcedure .input(InsertMemberSchema.pick({ id: true })) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_MEMBERS", "IS_OFFICER"], ctx); + permissions.controlPerms.or(["EDIT_MEMBERS", "IS_OFFICER"], ctx); if (!input.id) throw new TRPCError({ @@ -644,7 +645,7 @@ export const memberRouter = { where: eq(Member.id, input.id), columns: { firstName: true, lastName: true }, }); - await log({ + await discord.log({ title: "Dues Status Accredited", message: `${member?.firstName} ${member?.lastName} has been accredited dues status.`, color: "success_green", @@ -655,7 +656,7 @@ export const memberRouter = { deleteDuesPayingMember: permProcedure .input(InsertMemberSchema.pick({ id: true })) .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_MEMBERS", "IS_OFFICER"], ctx); + permissions.controlPerms.or(["EDIT_MEMBERS", "IS_OFFICER"], ctx); if (!input.id) throw new TRPCError({ @@ -667,7 +668,7 @@ export const memberRouter = { where: eq(Member.id, input.id), columns: { firstName: true, lastName: true }, }); - await log({ + await discord.log({ title: "Dues Status Revoked", message: `${member?.firstName} ${member?.lastName} has been revoked of dues status.`, color: "uhoh_red", @@ -676,10 +677,10 @@ export const memberRouter = { }), clearAllDues: permProcedure.mutation(async ({ ctx }) => { - controlPerms.or(["IS_OFFICER"], ctx); + permissions.controlPerms.or(["IS_OFFICER"], ctx); await db.delete(DuesPayment); - await log({ + await discord.log({ title: "ALL DUES CLEARED", message: "ALL DUES HAVE BEEN CLEARED. THIS ACTION IS REVERSIBLE FOR ONLY 7 DAYS.", @@ -697,7 +698,10 @@ export const memberRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["CHECKIN_CLUB_EVENT", "CHECKIN_HACK_EVENT"], ctx); + permissions.controlPerms.or( + ["CHECKIN_CLUB_EVENT", "CHECKIN_HACK_EVENT"], + ctx, + ); const member = await db.query.Member.findFirst({ where: eq(Member.userId, input.userId), @@ -747,7 +751,7 @@ export const memberRouter = { .update(Member) .set({ points: sql`${Member.points} + ${input.eventPoints}` }) .where(eq(Member.id, member.id)); - await log({ + await discord.log({ title: "User Checked-In", message: `${member.firstName} ${member.lastName} has been checked in to event ${event.name}.`, color: "success_green", diff --git a/packages/api/src/routers/misc.ts b/packages/api/src/routers/misc.ts index ae72d3141..ee98cd3c0 100644 --- a/packages/api/src/routers/misc.ts +++ b/packages/api/src/routers/misc.ts @@ -4,9 +4,9 @@ import { Routes } from "discord-api-types/v10"; import { z } from "zod"; import { DISCORD, FORMS, TEAM } from "@forge/consts"; +import * as discord from "@forge/utils/discord"; import { protectedProcedure } from "../trpc"; -import { discord } from "../utils"; export interface FundingRequestInput { team: string; @@ -94,13 +94,7 @@ export const miscRouter = { try { const discId = ctx.session.user.discordUserId; - await discord.put( - Routes.guildMemberRole( - DISCORD.KNIGHTHACKS_GUILD, - discId, - input.roleId, - ), - ); + await discord.addRoleToMember(discId, input.roleId); } catch (err) { throw new TRPCError({ message: `Could not assign role ${input.roleId} to user ${ctx.session.user.name}`, @@ -143,53 +137,57 @@ export const miscRouter = { // Convert hex color string to integer for Discord API const colorInt = parseInt(team.color.replace("#", ""), 16); - await discord.post(Routes.channelMessages(DISCORD.RECRUITING_CHANNEL), { - body: { - content: `<@&${directorRole}> **New Applicant for ${team.team}!**`, - embeds: [ - { - title: `${input.name}'s Application`, - description: `A new applicant is interested in joining the **${team.team}** team.\n\nPlease see details below:`, - color: colorInt, - fields: [ - { - name: "Name", - value: input.name, - inline: true, + // TODO: refactor to util + await discord.api.post( + Routes.channelMessages(DISCORD.RECRUITING_CHANNEL), + { + body: { + content: `<@&${directorRole}> **New Applicant for ${team.team}!**`, + embeds: [ + { + title: `${input.name}'s Application`, + description: `A new applicant is interested in joining the **${team.team}** team.\n\nPlease see details below:`, + color: colorInt, + fields: [ + { + name: "Name", + value: input.name, + inline: true, + }, + { + name: "Email", + value: input.email, + inline: true, + }, + { + name: "Major", + value: input.major, + inline: true, + }, + { + name: "Grad Term", + value: input.gradTerm, + inline: true, + }, + { + name: "Grad Year", + value: input.gradYear.toString(), + inline: true, + }, + { + name: "Team", + value: team.team, + inline: true, + }, + ], + footer: { + text: `Submitted at: ${new Date().toLocaleString()}`, }, - { - name: "Email", - value: input.email, - inline: true, - }, - { - name: "Major", - value: input.major, - inline: true, - }, - { - name: "Grad Term", - value: input.gradTerm, - inline: true, - }, - { - name: "Grad Year", - value: input.gradYear.toString(), - inline: true, - }, - { - name: "Team", - value: team.team, - inline: true, - }, - ], - footer: { - text: `Submitted at: ${new Date().toLocaleString()}`, + timestamp: new Date().toISOString(), }, - timestamp: new Date().toISOString(), - }, - ], + ], + }, }, - }); + ); }), } satisfies TRPCRouterRecord; diff --git a/packages/api/src/routers/passkit.ts b/packages/api/src/routers/passkit.ts index 7eb878454..ed4910813 100644 --- a/packages/api/src/routers/passkit.ts +++ b/packages/api/src/routers/passkit.ts @@ -5,6 +5,7 @@ import { TRPCError } from "@trpc/server"; import { PKPass } from "passkit-generator"; import { db } from "@forge/db/client"; +import { logger } from "@forge/utils"; import { env } from "../env"; import { protectedProcedure } from "../trpc"; @@ -144,7 +145,7 @@ export const passkitRouter = { fileName: fileName, }; } catch (error) { - console.error("Error generating passkit pass:", error); + logger.error("Error generating passkit pass:", error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Failed to generate passkit pass: ${error instanceof Error ? error.message : "Unknown error"}`, diff --git a/packages/api/src/routers/resume.ts b/packages/api/src/routers/resume.ts index af6598088..bcfdac278 100644 --- a/packages/api/src/routers/resume.ts +++ b/packages/api/src/routers/resume.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { MINIO } from "@forge/consts"; import { db } from "@forge/db/client"; +import { logger } from "@forge/utils"; import { env } from "../env"; import { protectedProcedure } from "../trpc"; @@ -87,14 +88,14 @@ export const resumeRouter = { // If neither member nor hacker found, return null if (!member && !hacker) { - console.error("No resume found for user"); + logger.error("No resume found for user"); return { url: null }; } const filename = member?.resumeUrl ?? hacker?.resumeUrl; if (!filename) { - console.error("No resume URL found for user"); + logger.error("No resume URL found for user"); return { url: null }; } diff --git a/packages/api/src/routers/roles.ts b/packages/api/src/routers/roles.ts index c669d4e2b..784b711c9 100644 --- a/packages/api/src/routers/roles.ts +++ b/packages/api/src/routers/roles.ts @@ -8,16 +8,10 @@ import { DISCORD, PERMISSIONS } from "@forge/consts"; import { eq, inArray, sql } from "@forge/db"; import { db } from "@forge/db/client"; import { Permissions, Roles, User } from "@forge/db/schemas/auth"; +import { logger, permissions } from "@forge/utils"; +import * as discord from "@forge/utils/discord"; import { permProcedure, protectedProcedure } from "../trpc"; -import { - addRoleToMember, - controlPerms, - discord, - getPermsAsList, - log, - removeRoleFromMember, -} from "../utils"; export const rolesRouter = { // ROLES @@ -31,7 +25,7 @@ export const rolesRouter = { }), ) .mutation(async ({ ctx, input }) => { - controlPerms.or(["CONFIGURE_ROLES"], ctx); + permissions.controlPerms.or(["CONFIGURE_ROLES"], ctx); // check for duplicate discord role const dupe = await db.query.Roles.findFirst({ @@ -70,7 +64,7 @@ export const rolesRouter = { for (const bladeUser of bladeUsers) { try { - const guildMember = (await discord.get( + const guildMember = (await discord.api.get( Routes.guildMember( DISCORD.KNIGHTHACKS_GUILD, bladeUser.discordUserId, @@ -98,19 +92,19 @@ export const rolesRouter = { } } - await log({ + await discord.log({ title: `Created Role: ${input.name}`, message: `Role linked to <@&${input.roleId}> - \n**Permissions:** ${getPermsAsList(input.permissions).join(", ")} + \n**Permissions:** ${permissions.getPermsAsList(input.permissions).join(", ")} \n**Auto-synced:** ${syncedCount} user(s) granted (checked ${checkedCount} Blade users)`, color: "blade_purple", userId: ctx.session.user.discordUserId, }); } catch { - await log({ + await discord.log({ title: `Created Role: ${input.name}`, message: `Role linked to <@&${input.roleId}> - \n**Permissions:** ${getPermsAsList(input.permissions).join(", ")} + \n**Permissions:** ${permissions.getPermsAsList(input.permissions).join(", ")} \n**Note:** Auto-sync unavailable. Checked ${checkedCount} users, synced ${syncedCount}.`, color: "blade_purple", userId: ctx.session.user.discordUserId, @@ -128,7 +122,7 @@ export const rolesRouter = { }), ) .mutation(async ({ ctx, input }) => { - controlPerms.or(["CONFIGURE_ROLES"], ctx); + permissions.controlPerms.or(["CONFIGURE_ROLES"], ctx); // check for existing role const exist = await db.query.Roles.findFirst({ @@ -160,12 +154,12 @@ export const rolesRouter = { }) .where(eq(Roles.id, input.id)); - await log({ + await discord.log({ title: `Updated Role`, message: `The **${exist.name}** Role (<@&${input.roleId}>) role has been updated. \n**Name:** ${exist.name} -> ${input.name} - \n**Original Perms:**\n${getPermsAsList(exist.permissions).join("\n")} - \n**New Perms:**\n${getPermsAsList(input.permissions).join("\n")}`, + \n**Original Perms:**\n${permissions.getPermsAsList(exist.permissions).join("\n")} + \n**New Perms:**\n${permissions.getPermsAsList(input.permissions).join("\n")}`, color: "blade_purple", userId: ctx.session.user.discordUserId, }); @@ -174,7 +168,7 @@ export const rolesRouter = { deleteRoleLink: permProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - controlPerms.or(["CONFIGURE_ROLES"], ctx); + permissions.controlPerms.or(["CONFIGURE_ROLES"], ctx); // check for existing role const exist = await db.query.Roles.findFirst({ @@ -188,7 +182,7 @@ export const rolesRouter = { await db.delete(Roles).where(eq(Roles.id, input.id)); - await log({ + await discord.log({ title: `Deleted Role`, message: `The **${exist.name}** Role (<@&${exist.discordRoleId}>) role has been deleted.`, color: "uhoh_red", @@ -212,7 +206,7 @@ export const rolesRouter = { .input(z.object({ roleId: z.string() })) .query(async ({ input }): Promise => { try { - return (await discord.get( + return (await discord.api.get( Routes.guildRole(DISCORD.KNIGHTHACKS_GUILD, input.roleId), )) as APIRole | null; } catch { @@ -230,7 +224,7 @@ export const rolesRouter = { for (const r of input.roles) { try { ret.push( - (await discord.get( + (await discord.api.get( Routes.guildRole(DISCORD.KNIGHTHACKS_GUILD, r.discordRoleId), )) as APIRole | null, ); @@ -244,7 +238,7 @@ export const rolesRouter = { getDiscordRoleCounts: protectedProcedure.query( async (): Promise | null> => { - return (await discord.get( + return (await discord.api.get( `/guilds/${DISCORD.KNIGHTHACKS_GUILD}/roles/member-counts`, )) as Record; }, @@ -300,8 +294,8 @@ export const rolesRouter = { ) .query(({ input, ctx }) => { try { - if (input.or) controlPerms.or(input.or, ctx); - if (input.and) controlPerms.and(input.and, ctx); + if (input.or) permissions.controlPerms.or(input.or, ctx); + if (input.and) permissions.controlPerms.and(input.and, ctx); } catch { return false; } @@ -312,7 +306,7 @@ export const rolesRouter = { grantPermission: permProcedure .input(z.object({ roleId: z.string(), userId: z.string() })) .mutation(async ({ input, ctx }) => { - controlPerms.or(["ASSIGN_ROLES"], ctx); + permissions.controlPerms.or(["ASSIGN_ROLES"], ctx); const exists = await db.query.Permissions.findFirst({ where: (t, { eq, and }) => @@ -344,16 +338,16 @@ export const rolesRouter = { // Note: This may fail due to role hierarchy or bot permissions // We log the error but don't break the flow - Blade permission is still granted try { - await addRoleToMember(user.discordUserId, role.discordRoleId); - console.log( + await discord.addRoleToMember(user.discordUserId, role.discordRoleId); + logger.log( `Successfully added Discord role ${role.discordRoleId} to user ${user.discordUserId}`, ); } catch (error) { - console.error( + logger.error( `Failed to add Discord role ${role.discordRoleId} to user ${user.discordUserId}:`, error, ); - console.error( + logger.error( ` This may be due to role hierarchy or bot permissions. Blade permission will still be granted.`, ); } @@ -363,7 +357,7 @@ export const rolesRouter = { userId: input.userId, }); - await log({ + await discord.log({ title: `Granted Role`, message: `The **${role.name}** role (<@&${role.discordRoleId}>) has been granted to <@${user.discordUserId}>.`, color: "success_green", @@ -374,7 +368,7 @@ export const rolesRouter = { revokePermission: permProcedure .input(z.object({ roleId: z.string(), userId: z.string() })) .mutation(async ({ input, ctx }) => { - controlPerms.or(["ASSIGN_ROLES"], ctx); + permissions.controlPerms.or(["ASSIGN_ROLES"], ctx); const perm = await db.query.Permissions.findFirst({ where: (t, { eq, and }) => @@ -407,23 +401,26 @@ export const rolesRouter = { // Note: This may fail due to role hierarchy or bot permissions // We log the error but don't break the flow - Blade permission is still revoked try { - await removeRoleFromMember(user.discordUserId, role.discordRoleId); - console.log( + await discord.removeRoleFromMember( + user.discordUserId, + role.discordRoleId, + ); + logger.log( `✅ Successfully removed Discord role ${role.discordRoleId} from user ${user.discordUserId}`, ); } catch (error) { - console.error( + logger.error( `Failed to remove Discord role ${role.discordRoleId} from user ${user.discordUserId}:`, error, ); - console.error( + logger.error( ` This may be due to role hierarchy or bot permissions. Blade permission will still be revoked.`, ); } await db.delete(Permissions).where(eq(Permissions.id, perm.id)); - await log({ + await discord.log({ title: `Revoked Role`, message: `The **${role.name}** role (<@&${role.discordRoleId}>) has been revoked from <@${user.discordUserId}>.`, color: "uhoh_red", @@ -440,7 +437,7 @@ export const rolesRouter = { }), ) .mutation(async ({ input, ctx }) => { - controlPerms.or(["ASSIGN_ROLES"], ctx); + permissions.controlPerms.or(["ASSIGN_ROLES"], ctx); interface Return { roleName: string; @@ -494,12 +491,12 @@ export const rolesRouter = { if (!input.revoking) { // Granting role - Discord may fail due to hierarchy/perms try { - await addRoleToMember( + await discord.addRoleToMember( userData.discordUserId, roleData.discordRoleId, ); } catch (discordError) { - console.error( + logger.error( `Discord role grant failed for ${userData.name} -> ${roleData.name}:`, discordError, ); @@ -512,12 +509,12 @@ export const rolesRouter = { } else if (perm) { // Revoking role - Discord may fail due to hierarchy/perms try { - await removeRoleFromMember( + await discord.removeRoleFromMember( userData.discordUserId, roleData.discordRoleId, ); } catch (discordError) { - console.error( + logger.error( `Discord role revoke failed for ${userData.name} -> ${roleData.name}:`, discordError, ); @@ -529,7 +526,7 @@ export const rolesRouter = { } } catch (error) { // This catches DB errors only (Discord errors are caught above) - console.error( + logger.error( `Database error for ${input.revoking ? "revoke" : "grant"} role ${roleData.name} ${input.revoking ? "from" : "to"} ${userData.name}:`, error, ); @@ -545,7 +542,7 @@ export const rolesRouter = { failed.map((v) => `${v.userName} -> ${v.roleName}`).join("\n") : ""; - await log({ + await discord.log({ title: `${input.revoking ? "Revoked" : "Granted"} Batch Roles`, message: `The following roles have been ${input.revoking ? "revoked from" : "granted to"} the following users:\n\n` + diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts index 0983aa0b1..d60c1d93f 100644 --- a/packages/api/src/routers/user.ts +++ b/packages/api/src/routers/user.ts @@ -1,9 +1,9 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { db } from "@forge/db/client"; +import { permissions } from "@forge/utils"; import { permProcedure, protectedProcedure } from "../trpc"; -import { controlPerms } from "../utils"; // // helper schema to check if a value is either of type PermissionKey or PermissionIndex // // z.custom doesn't perform any validation by itself, so it will let any type at runtime @@ -39,7 +39,7 @@ export const userRouter = { // Also appends roles to returned users getUsers: permProcedure.query(async ({ ctx }) => { - controlPerms.or(["CONFIGURE_ROLES"], ctx); + permissions.controlPerms.or(["CONFIGURE_ROLES"], ctx); const users = await db.query.User.findMany({ with: { permissions: true, diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index a82242edc..904280825 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -1,3 +1,5 @@ +/* eslint-disable no-console */ + /** * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: * 1. You want to modify request context (see Part 1) @@ -16,12 +18,8 @@ import { PERMISSIONS } from "@forge/consts"; import { eq, sql } from "@forge/db"; import { db } from "@forge/db/client"; import { Permissions, Roles } from "@forge/db/schemas/auth"; - -import { - getJudgeSessionFromCookie, - isDiscordAdmin, - isJudgeAdmin, -} from "./utils"; +import * as discord from "@forge/utils/discord"; +import * as permissionsServer from "@forge/utils/permissions.server"; /** * 1. CONTEXT @@ -183,15 +181,15 @@ export const permProcedure = protectedProcedure.use(async ({ ctx, next }) => { export const judgeProcedure = publicProcedure.use(async ({ ctx, next }) => { let isAdmin; if (ctx.session) { - isAdmin = await isDiscordAdmin(ctx.session.user); + isAdmin = await discord.isDiscordAdmin(ctx.session.user); } - const isJudge = await isJudgeAdmin(); + const isJudge = await permissionsServer.isJudgeAdmin(); if (!isAdmin && !isJudge) { throw new TRPCError({ code: "UNAUTHORIZED" }); } - const judgeSession = await getJudgeSessionFromCookie(); + const judgeSession = await permissionsServer.getJudgeSessionFromCookie(); return next({ ctx: { diff --git a/packages/api/src/utils.ts b/packages/api/src/utils.ts deleted file mode 100644 index aac26ac8d..000000000 --- a/packages/api/src/utils.ts +++ /dev/null @@ -1,611 +0,0 @@ -import type { APIGuildMember } from "discord-api-types/v10"; -import type { JSONSchema7 } from "json-schema"; -import { cookies } from "next/headers"; -import { REST } from "@discordjs/rest"; -import { TRPCError } from "@trpc/server"; -import { Routes } from "discord-api-types/v10"; -import { and, desc, eq, gt, inArray } from "drizzle-orm"; -import { google } from "googleapis"; -import Stripe from "stripe"; -import z from "zod"; - -import type { Session } from "@forge/auth/server"; -import type { Form } from "@forge/db/schemas/knight-hacks"; -import { DISCORD, EVENTS, FORMS, MINIO, PERMISSIONS } from "@forge/consts"; -import { db } from "@forge/db/client"; -import { Account, JudgeSession, Roles } from "@forge/db/schemas/auth"; -import { FormSchemaSchema, FormsSchemas } from "@forge/db/schemas/knight-hacks"; -import { client } from "@forge/email"; - -import { env } from "./env"; -import { minioClient } from "./minio/minio-client"; - -export const discord = new REST({ version: "10" }).setToken( - env.DISCORD_BOT_TOKEN, -); - -export async function addRoleToMember(discordUserId: string, roleId: string) { - await discord.put( - Routes.guildMemberRole(DISCORD.KNIGHTHACKS_GUILD, discordUserId, roleId), - ); -} - -export async function removeRoleFromMember( - discordUserId: string, - roleId: string, -) { - await discord.delete( - Routes.guildMemberRole(DISCORD.KNIGHTHACKS_GUILD, discordUserId, roleId), - ); -} - -export async function addMemberToServer( - discordUserId: string, - accessToken: string, -): Promise { - try { - await discord.put( - Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, discordUserId), - { - body: { - access_token: accessToken, - }, - }, - ); - - console.log(`Added ${discordUserId} to the KH discord server`); - return; - } catch (error) { - console.error( - `Failed to add user ${discordUserId} to the KH discord server:`, - error instanceof Error ? error.message : "Unknown error", - ); - } -} - -export async function handleDiscordOAuthCallback( - discordUserId: string, -): Promise { - try { - const user = await db.query.User.findFirst({ - where: (u, { eq }) => eq(u.discordUserId, discordUserId), - }); - - if (!user) { - return; - } - - const accounts = await db - .select({ account: Account }) - .from(Account) - .where(and(eq(Account.provider, "discord"), eq(Account.userId, user.id))) - .orderBy(desc(Account.updatedAt)) - .limit(1); - - const account = accounts[0]?.account; - const accessToken = account?.access_token; - const scope = account?.scope; - - if (accessToken && scope?.includes("guilds.join")) { - void addMemberToServer(discordUserId, accessToken); - } - } catch (error) { - console.error( - `Failed to handle Discord OAuth callback for ${discordUserId}:`, - error instanceof Error ? error.message : "Unknown error", - ); - } -} - -export async function resolveDiscordUserId( - username: string, -): Promise { - const q = username.trim().toLowerCase(); - const members = (await discord.get( - `${Routes.guildMembersSearch(DISCORD.KNIGHTHACKS_GUILD)}?query=${encodeURIComponent(q)}&limit=1`, - )) as APIGuildMember[]; - return members[0]?.user.id ?? null; -} - -export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { typescript: true }); - -export const isDiscordAdmin = async (user: Session["user"]) => { - try { - const guildMember = (await discord.get( - Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, user.discordUserId), - )) as APIGuildMember; - return guildMember.roles.includes(DISCORD.ADMIN_ROLE); - } catch (err) { - console.error("Error: ", err); - return false; - } -}; - -export const hasPermission = ( - userPermissions: string, - permission: PERMISSIONS.PermissionIndex, -): boolean => { - const permissionBit = userPermissions[permission]; - return permissionBit === "1"; -}; - -export const parsePermissions = async (discordUserId: string) => { - const guildMember = (await discord.get( - Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, discordUserId), - )) as APIGuildMember; - - const permissionsLength = Object.keys(PERMISSIONS.PERMISSIONS).length; - - // array of booleans. the boolean value at the index indicates if the user has that permission. - // true means the user has the permission, false means the user doesn't have the permission. - const permissionsBits = new Array(permissionsLength).fill(false) as boolean[]; - - if (guildMember.roles.length > 0) { - // get only roles the user has - const userDbRoles = await db - .select() - .from(Roles) - .where(inArray(Roles.discordRoleId, guildMember.roles)); - - for (const role of userDbRoles) { - if (!role.permissions) continue; - - for ( - let i = 0; - i < role.permissions.length && i < permissionsLength; - ++i - ) { - if (role.permissions[i] === "1") { - permissionsBits[i] = true; - } - } - } - } - - // creates the map of permissions to their boolean values - const permissionsMap = Object.keys(PERMISSIONS.PERMISSIONS).reduce( - (accumulator, key) => { - const index = PERMISSIONS.PERMISSIONS[key]; - if (index === undefined) return accumulator; - - accumulator[key] = permissionsBits[index] ?? false; - - return accumulator; - }, - {} as Record, - ); - - return permissionsMap; -}; - -// Mock tRPC context for type-safety -interface Context { - session: { - permissions: Record; - }; -} - -export const controlPerms = { - // Returns true if the user has any required permission OR has isOfficer role - or: (perms: PERMISSIONS.PermissionKey[], ctx: Context) => { - // first check if user has IS_OFFICER - if (ctx.session.permissions.IS_OFFICER) return true; - - let flag = false; - for (const p of perms) if (ctx.session.permissions[p]) flag = true; - if (!flag) throw new TRPCError({ code: "UNAUTHORIZED" }); - return true; - }, - - // Returns true only if the user has ALL required permissions - and: (perms: PERMISSIONS.PermissionKey[], ctx: Context) => { - // first check if user has IS_OFFICER - if (ctx.session.permissions.IS_OFFICER) return true; - - for (const p of perms) - if (!ctx.session.permissions[p]) - throw new TRPCError({ code: "UNAUTHORIZED" }); - - return true; - }, -}; - -export const isDiscordMember = async (user: Session["user"]) => { - try { - await discord.get( - Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, user.discordUserId), - ); - return true; - } catch { - return false; - } -}; - -export async function isDiscordVIP(discordUserId: string) { - const guildMember = (await discord.get( - Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, discordUserId), - )) as APIGuildMember; - return guildMember.roles.includes(DISCORD.VIP_ROLE); -} - -export const sendEmail = async ({ - to, - subject, - template_id, - from, - data, -}: { - to: string | string[]; - subject: string; - template_id: number; - data: Record; - from?: string; -}): Promise<{ success: true }> => { - try { - await client.tx.send({ - template_id: template_id, - from_email: from ?? env.LISTMONK_FROM_EMAIL, - subscriber_mode: "external", - subscriber_emails: typeof to === "string" ? [to] : to, - subject: subject, - data: data, - }); - - return { success: true }; - } catch (error) { - console.error("Error sending email:", error); - throw new Error( - `Failed to send email: ${ - error instanceof Error ? error.message : "Unknown error" - }`, - ); - } -}; - -export async function log({ - title, - message, - color, - userId, -}: { - title: string; - message: string; - color: "tk_blue" | "blade_purple" | "uhoh_red" | "success_green"; - userId: string; -}) { - await discord.post(Routes.channelMessages(DISCORD.LOG_CHANNEL), { - body: { - embeds: [ - { - title: title, - description: message + `\n\nUser: <@${userId}>`.toString(), - color: { - tk_blue: 0x1a73e8, - blade_purple: 0xcca4f4, - uhoh_red: 0xff0000, - success_green: 0x00ff00, - }[color], - footer: { - text: new Date().toLocaleString(), - }, - }, - ], - }, - }); -} - -export const isJudgeAdmin = async () => { - try { - const token = cookies().get("sessionToken")?.value; - if (!token) return false; - - const now = new Date(); - const rows = await db - .select({ sessionToken: JudgeSession.sessionToken }) - .from(JudgeSession) - .where( - and( - eq(JudgeSession.sessionToken, token), - gt(JudgeSession.expires, now), - ), - ) - .limit(1); - - return rows.length > 0; - } catch (err) { - console.error("isJudgeAdmin DB check error:", err); - return false; - } -}; - -export const getJudgeSessionFromCookie = async () => { - const token = cookies().get("sessionToken")?.value; - if (!token) return null; - - const now = new Date(); - const rows = await db - .select({ - sessionToken: JudgeSession.sessionToken, - roomName: JudgeSession.roomName, - expires: JudgeSession.expires, - }) - .from(JudgeSession) - .where( - and(eq(JudgeSession.sessionToken, token), gt(JudgeSession.expires, now)), - ) - .limit(1); - - return rows[0] ?? null; -}; - -const GOOGLE_PRIVATE_KEY = Buffer.from(env.GOOGLE_PRIVATE_KEY_B64, "base64") - .toString("utf-8") - .replace(/\\n/g, "\n"); - -const gapiCalendar = "https://www.googleapis.com/auth/calendar"; -const gapiGmailSend = "https://www.googleapis.com/auth/gmail.send"; -const gapiGmailSettingsSharing = - "https://www.googleapis.com/auth/gmail.settings.sharing"; - -const auth = new google.auth.JWT( - env.GOOGLE_CLIENT_EMAIL, - undefined, - GOOGLE_PRIVATE_KEY, - [gapiCalendar, gapiGmailSend, gapiGmailSettingsSharing], - EVENTS.GOOGLE_PERSONIFY_EMAIL as string, -); - -export const gmail = google.gmail({ - version: "v1", - auth: auth, -}); - -export const calendar = google.calendar({ - version: "v3", - auth: auth, -}); - -type OptionalSchema = - | { success: true; schema: JSONSchema7 } - | { success: false; msg: string }; - -function createJsonSchemaValidator({ - optional, - type, - options, - optionsConst, - min, - max, - allowOther, -}: FORMS.ValidatorOptions): OptionalSchema { - const schema: JSONSchema7 = {}; - - const resolvedOptions = optionsConst - ? [...FORMS.getDropdownOptionsFromConst(optionsConst)] - : options; - - switch (type) { - case "SHORT_ANSWER": - case "PARAGRAPH": - schema.type = "string"; - if (max === undefined) { - schema.maxLength = type === "SHORT_ANSWER" ? 150 : 750; - } - break; - case "EMAIL": - schema.type = "string"; - schema.format = "email"; - break; - case "PHONE": - schema.type = "string"; - schema.pattern = "^\\+?\\d{7,15}$"; - break; - case "DATE": - schema.type = "string"; - schema.format = "date"; - break; - case "TIME": - schema.type = "string"; - schema.pattern = "^([01]\\d|2[0-3]):([0-5]\\d)$"; - break; - case "NUMBER": - case "LINEAR_SCALE": - schema.type = "number"; - break; - case "MULTIPLE_CHOICE": - case "DROPDOWN": - if (!resolvedOptions?.length) - return { - success: false, - msg: "Options are required for multiple choice / dropdown", - }; - schema.type = "string"; - if (!allowOther) { - schema.enum = resolvedOptions; - } - break; - case "CHECKBOXES": - if (!resolvedOptions?.length) - return { success: false, msg: "Options required for checkboxes" }; - schema.type = "array"; - if (allowOther) { - schema.items = { type: "string" }; - } else { - schema.items = { type: "string", enum: resolvedOptions }; - } - break; - case "FILE_UPLOAD": - schema.type = "string"; - break; - case "BOOLEAN": - schema.type = "boolean"; - break; - case "LINK": - schema.type = "string"; - schema.format = "uri"; - break; - default: - schema.type = "string"; - } - - if (min !== undefined) { - if (schema.type === "string") schema.minLength = min; - if (schema.type === "array") schema.minItems = min; - if (schema.type === "number") schema.minimum = min; - } else { - if (schema.type === "array" && !optional) schema.minItems = 1; - } - - if (max !== undefined) { - if (schema.type === "string") { - // Explicit max value overrides any defaults - schema.maxLength = max; - } - if (schema.type === "array") schema.maxItems = max; - if (schema.type === "number") schema.maximum = max; - } - - return { success: true, schema }; -} - -export function generateJsonSchema(form: FORMS.FormType): OptionalSchema { - const schema: JSONSchema7 = { - type: "object", - properties: {}, - required: [], - additionalProperties: false, - }; - - const properties: Record = {}; - const required: string[] = []; - - for (const formQuestion of form.questions) { - const { question, optional, ...rest } = formQuestion; - const convert = createJsonSchemaValidator({ optional, ...rest }); - if (convert.success) properties[question] = convert.schema; - else return convert; - - if (!optional) { - required.push(question); - } - } - - schema.properties = properties; - if (required.length > 0) { - schema.required = required; - } - - return { success: true, schema }; -} - -// Helper to regenerate presigned URLs for media -export async function regenerateMediaUrls( - instructions: FORMS.FormType["instructions"], -) { - if (!instructions) return []; - const updatedQuestions = await Promise.all( - instructions.map(async (i) => { - const updated = { ...i }; - - // Regenerate image URL if objectName exists - if ("imageObjectName" in i && i.imageObjectName) { - try { - updated.imageUrl = await minioClient.presignedGetObject( - MINIO.FORM_ASSETS_BUCKET_NAME, - i.imageObjectName, - MINIO.PRESIGNED_URL_EXPIRY, - ); - } catch (e) { - console.error("Failed to regenerate image URL:", e); - } - } - - // Regenerate video URL if objectName exists - if ("videoObjectName" in i && i.videoObjectName) { - try { - updated.videoUrl = await minioClient.presignedGetObject( - MINIO.FORM_ASSETS_BUCKET_NAME, - i.videoObjectName, - MINIO.PRESIGNED_URL_EXPIRY, - ); - } catch (e) { - console.error("Failed to regenerate video URL:", e); - } - } - - return updated; - }), - ); - - return updatedQuestions; -} - -export function getPermsAsList(perms: string) { - const list = []; - const permKeys = Object.keys(PERMISSIONS.PERMISSIONS); - for (let i = 0; i < perms.length; i++) { - const permKey = permKeys.at(i); - if (perms[i] == "1" && permKey) { - const permissionData = PERMISSIONS.PERMISSION_DATA[permKey]; - if (permissionData) list.push(permissionData.name); - } - } - return list; -} - -// All of this will be moved to @forge/utils but its here for now -export const CreateFormSchema = FormSchemaSchema.omit({ - id: true, - name: true, - slugName: true, - createdAt: true, - formData: true, - formValidatorJson: true, -}) - .extend({ formData: FORMS.FormSchemaValidator }) - .extend({ section: z.string().optional() }); - -type CreateFormType = z.infer; - -export async function createForm(input: CreateFormType): Promise
{ - const jsonSchema = generateJsonSchema(input.formData); - - const slug_name = input.formData.name.toLowerCase().replaceAll(" ", "-"); - - if (!jsonSchema.success) { - throw new TRPCError({ - message: jsonSchema.msg, - code: "BAD_REQUEST", - }); - } - - let sectionId: string | null = null; - const sectionName = input.section ?? "General"; - - if (sectionName !== "General") { - const section = await db.query.FormSections.findFirst({ - where: (t, { eq }) => eq(t.name, sectionName), - }); - sectionId = section?.id ?? null; - } - - const [form] = await db - .insert(FormsSchemas) - .values({ - ...input, - name: input.formData.name, - slugName: slug_name, - formValidatorJson: jsonSchema.schema, - sectionId, - }) - .returning(); - - if (!form) { - throw new TRPCError({ - message: "Could not create form", - code: "INTERNAL_SERVER_ERROR", - }); - } - - return form; -} diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 28a11f9cf..6059f02f8 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -7,7 +7,7 @@ import { eq } from "drizzle-orm"; import { db } from "@forge/db/client"; import { Account, Session, User, Verifications } from "@forge/db/schemas/auth"; -import { handleDiscordOAuthCallback } from "../../api/src/utils"; +import * as discord from "../../utils/src/discord"; import { env } from "./env"; export const isSecureContext = env.NODE_ENV !== "development"; @@ -72,8 +72,9 @@ export const auth = betterAuth({ const discordUserId = user?.discordUserId; if (!discordUserId) return; - void handleDiscordOAuthCallback(discordUserId); + await discord.handleDiscordOAuthCallback(discordUserId); } catch (error) { + // TODO: remove this eslint-disable // eslint-disable-next-line no-console console.error("Error in Discord auto join hook:", error); } diff --git a/packages/db/scripts/bootstrap-superadmin.ts b/packages/db/scripts/bootstrap-superadmin.ts index 2d4d0ee2c..0a924579e 100644 --- a/packages/db/scripts/bootstrap-superadmin.ts +++ b/packages/db/scripts/bootstrap-superadmin.ts @@ -1,4 +1,6 @@ +// TODO: use a real logger to avoid this issue /* eslint-disable no-console */ + /** * ONE-TIME BOOTSTRAP SCRIPT // This script creates a superadmin role with all permissions and assigns it to a user. diff --git a/packages/db/scripts/get_prod_db.ts b/packages/db/scripts/get_prod_db.ts index def6f3b6d..d39cc970c 100644 --- a/packages/db/scripts/get_prod_db.ts +++ b/packages/db/scripts/get_prod_db.ts @@ -1,4 +1,6 @@ +// TODO: use a real logger to avoid this issue /* eslint-disable no-console */ + /** * Usage: * pnpm --filter=@forge/db with-env tsx scripts/get_prod_db.ts [--truncate] diff --git a/packages/db/scripts/seed_devdb.ts b/packages/db/scripts/seed_devdb.ts index ae8126bac..56b5566ab 100644 --- a/packages/db/scripts/seed_devdb.ts +++ b/packages/db/scripts/seed_devdb.ts @@ -1,8 +1,22 @@ +// TODO: use a real logger to avoid this issue /* eslint-disable no-console */ + // Usage: // pnpm --filter @forge/db with-env tsx scripts/seed_devdb.ts -// A script to be run on prod only, this will take the prod db and make a backup sql script to insert all rows that don't have sensitive user data. It will only keep data from our admin members and delete any judging data/other sensitive data. It will also take all the server specific discord IDs in the DB and then sync them up with an event/role in the dev server and change the ID in the db for the local version. This sql file is uploaded to our minio client to be pulled by the get_prod_db.ts script. There's no realistic reason for this script to ever be ran on dev unless you're updating it cause I probably messed a lot up :D. See get_prod_db.ts for how to get prod data into your local db for deving. +// A script to be run on prod only, this will take the prod db and make a +// backup sql script to insert all rows that don't have sensitive user data. It +// will only keep data from our admin members and delete any judging data/other +// sensitive data. It will also take all the server specific discord IDs in the +// DB and then sync them up with an event/role in the dev server and change the +// ID in the db for the local version. This sql file is uploaded to our minio +// client to be pulled by the get_prod_db.ts script. There's no realistic +// reason for this script to ever be ran on dev unless you're updating it cause +// I probably messed a lot up :D. See get_prod_db.ts for how to get prod data +// into your local db for deving. + +// TODO: look into moving into a separate area so we don't have to do the BS +// that we do with `../../api` and `../../utils` import { exec } from "child_process"; import { unlink } from "fs/promises"; @@ -17,8 +31,9 @@ import { stringify } from "superjson"; import { DISCORD, MINIO } from "@forge/consts"; +// Scripts can use relative imports to avoid circular dependencies import { minioClient } from "../../api/src/minio/minio-client"; -import { discord, log } from "../../api/src/utils"; +import * as discord from "../../utils/src/discord"; import { env } from "../src/env"; import * as authSchema from "../src/schemas/auth"; import * as knightHacksSchema from "../src/schemas/knight-hacks"; @@ -228,12 +243,12 @@ async function syncRoles() { await backupDb.query.Roles.findMany({ columns: { discordRoleId: true } }) ).map((row) => row.discordRoleId), ); - let prodRoles = (await discord.get( + let prodRoles = (await discord.api.get( Routes.guildRoles(DISCORD.PROD_KNIGHTHACKS_GUILD), )) as DiscordRole[]; prodRoles = prodRoles.filter((role) => prodRolesWithPerms.has(role.id)); - const devRolesArr = (await discord.get( + const devRolesArr = (await discord.api.get( Routes.guildRoles(DISCORD.DEV_KNIGHTHACKS_GUILD), )) as DiscordRole[]; const devRoles = Object.fromEntries( @@ -246,7 +261,7 @@ async function syncRoles() { roleIdMappings[role.id] = devRoles[hash].id; } else { await new Promise((resolve) => setTimeout(resolve, 100)); - const newRole = (await discord.post( + const newRole = (await discord.api.post( Routes.guildRoles(DISCORD.DEV_KNIGHTHACKS_GUILD), { body: { @@ -288,11 +303,11 @@ interface DiscordGuildScheduledEvent { async function syncEvents() { if (!backupDb) return; - const prodEvents = (await discord.get( + const prodEvents = (await discord.api.get( Routes.guildScheduledEvents(DISCORD.PROD_KNIGHTHACKS_GUILD), )) as DiscordGuildScheduledEvent[]; - const devEventsArr = (await discord.get( + const devEventsArr = (await discord.api.get( Routes.guildScheduledEvents(DISCORD.DEV_KNIGHTHACKS_GUILD), )) as DiscordGuildScheduledEvent[]; const devEvents = Object.fromEntries( @@ -305,7 +320,7 @@ async function syncEvents() { eventIdMappings[event.id] = devEvents[hash].id; } else { await new Promise((resolve) => setTimeout(resolve, 100)); - const newEvent = (await discord.post( + const newEvent = (await discord.api.post( Routes.guildScheduledEvents(DISCORD.DEV_KNIGHTHACKS_GUILD), { body: { @@ -415,7 +430,7 @@ async function main() { console.log("Cleaning up backup db"); await cleanUp(); - await log({ + await discord.log({ title: `Successfully saved limited prod db to minio`, message: `Successfully saved limited prod db to minio. Run the get_prod_db.ts script to get it into your local dev db.`, color: "success_green", @@ -425,7 +440,7 @@ async function main() { process.exit(0); } catch (error) { console.error("Error during database seeding:", error); - await log({ + await discord.log({ title: `Failed to save limited prod db to minio`, message: `Failed to sav limited prod db to minio. Error: ${stringify(error)}`, color: "uhoh_red", diff --git a/packages/email/package.json b/packages/email/package.json index 78a5ab6f0..87afde0a3 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -25,6 +25,7 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { + "@forge/utils": "workspace:*", "@maloma/listmonk": "^1.0.1", "@t3-oss/env-nextjs": "^0.11.1", "minimatch": "^10.2.1" diff --git a/packages/email/src/env.ts b/packages/email/src/env.ts index 7acf1c513..7af470038 100644 --- a/packages/email/src/env.ts +++ b/packages/email/src/env.ts @@ -6,6 +6,7 @@ export const env = createEnv({ LISTMONK_URL: z.string(), LISTMONK_USER: z.string(), LISTMONK_TOKEN: z.string(), + LISTMONK_FROM_EMAIL: z.string(), }, experimental__runtimeEnv: {}, skipValidation: diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts index 1474e5d3a..4f93a4d5a 100644 --- a/packages/email/src/index.ts +++ b/packages/email/src/index.ts @@ -1,5 +1,7 @@ import { Listmonk } from "@maloma/listmonk"; +import { logger } from "@forge/utils"; + import { env } from "./env"; export const client = new Listmonk({ @@ -9,3 +11,37 @@ export const client = new Listmonk({ password: env.LISTMONK_TOKEN, }, }); + +export const sendEmail = async ({ + to, + subject, + template_id, + from, + data, +}: { + to: string | string[]; + subject: string; + template_id: number; + data: Record; + from?: string; +}): Promise<{ success: true }> => { + try { + await client.tx.send({ + template_id: template_id, + from_email: from ?? env.LISTMONK_FROM_EMAIL, + subscriber_mode: "external", + subscriber_emails: typeof to === "string" ? [to] : to, + subject: subject, + data: data, + }); + + return { success: true }; + } catch (error) { + logger.error("Error sending email:", error); + throw new Error( + `Failed to send email: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } +}; diff --git a/packages/utils/eslint.config.js b/packages/utils/eslint.config.js new file mode 100644 index 000000000..66675f5b8 --- /dev/null +++ b/packages/utils/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@forge/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, +]; diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 000000000..678f5c08f --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,48 @@ +{ + "name": "@forge/utils", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./discord": "./src/discord.ts", + "./forms": "./src/forms.ts", + "./forms.client": "./src/forms.client.ts", + "./google": "./src/google.ts", + "./permissions.server": "./src/permissions.server.ts", + "./stripe": "./src/stripe.ts" + }, + "license": "MIT", + "scripts": { + "build": "tsc", + "clean": "git clean -xdf .cache .turbo dist node_modules", + "dev": "tsc", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc --noEmit --emitDeclarationOnly false" + }, + "devDependencies": { + "@forge/auth": "workspace:*", + "@forge/consts": "workspace:*", + "@forge/db": "workspace:*", + "@forge/eslint-config": "workspace:*", + "@forge/prettier-config": "workspace:*", + "@forge/tsconfig": "workspace:*", + "@trpc/server": "catalog:", + "eslint": "catalog:", + "prettier": "catalog:", + "typescript": "catalog:", + "zod": "catalog:" + }, + "peerDependencies": { + "@trpc/server": "catalog:" + }, + "prettier": "@forge/prettier-config", + "dependencies": { + "@discordjs/rest": "^2.4.0", + "@t3-oss/env-nextjs": "^0.11.1", + "discord-api-types": "^0.37.113", + "googleapis": "^144.0.0", + "server-only": "^0.0.1" + } +} diff --git a/packages/utils/src/discord.ts b/packages/utils/src/discord.ts new file mode 100644 index 000000000..928286330 --- /dev/null +++ b/packages/utils/src/discord.ts @@ -0,0 +1,169 @@ +// +// Discord utils package. Holds all of the routes as well as the discord rest +// api client. +// + +import "server-only"; + +import type { APIGuildMember } from "discord-api-types/v10"; +import { REST } from "@discordjs/rest"; +import { Routes } from "discord-api-types/v10"; +import { and, desc, eq } from "drizzle-orm"; + +import type { Session } from "@forge/auth/server"; +import { DISCORD } from "@forge/consts"; +import { db } from "@forge/db/client"; +import { Account } from "@forge/db/schemas/auth"; + +import { env } from "./env"; +import { logger } from "./logger"; + +export const api = new REST({ version: "10" }).setToken(env.DISCORD_BOT_TOKEN); + +export async function addRoleToMember(discordUserId: string, roleId: string) { + await api.put( + Routes.guildMemberRole(DISCORD.KNIGHTHACKS_GUILD, discordUserId, roleId), + ); +} + +export async function removeRoleFromMember( + discordUserId: string, + roleId: string, +) { + await api.delete( + Routes.guildMemberRole(DISCORD.KNIGHTHACKS_GUILD, discordUserId, roleId), + ); +} + +export async function addMemberToServer( + discordUserId: string, + accessToken: string, +): Promise { + try { + await api.put( + Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, discordUserId), + { + body: { + access_token: accessToken, + }, + }, + ); + + logger.log(`Added ${discordUserId} to the KH discord server`); + return; + } catch (error) { + logger.error( + `Failed to add user ${discordUserId} to the KH discord server:`, + error instanceof Error ? error.message : "Unknown error", + ); + } +} + +export async function handleDiscordOAuthCallback( + discordUserId: string, +): Promise { + try { + const user = await db.query.User.findFirst({ + where: (u, { eq }) => eq(u.discordUserId, discordUserId), + }); + + if (!user) { + return; + } + + const accounts = await db + .select({ account: Account }) + .from(Account) + .where(and(eq(Account.provider, "discord"), eq(Account.userId, user.id))) + .orderBy(desc(Account.updatedAt)) + .limit(1); + + const account = accounts[0]?.account; + const accessToken = account?.access_token; + const scope = account?.scope; + + if (accessToken && scope?.includes("guilds.join")) { + void addMemberToServer(discordUserId, accessToken); + } + } catch (error) { + logger.error( + `Failed to handle Discord OAuth callback for ${discordUserId}:`, + error instanceof Error ? error.message : "Unknown error", + ); + } +} + +export async function resolveDiscordUserId( + username: string, +): Promise { + const q = username.trim().toLowerCase(); + const members = (await api.get( + `${Routes.guildMembersSearch(DISCORD.KNIGHTHACKS_GUILD)}?query=${encodeURIComponent(q)}&limit=1`, + )) as APIGuildMember[]; + return members[0]?.user.id ?? null; +} + +// TODO: look into not using Session here so we can remove the auth import +// which will let us clean up our imports. + +export const isDiscordAdmin = async (user: Session["user"]) => { + try { + const guildMember = (await api.get( + Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, user.discordUserId), + )) as APIGuildMember; + return guildMember.roles.includes(DISCORD.ADMIN_ROLE); + } catch (err) { + logger.error("Error: ", err); + return false; + } +}; + +export const isDiscordMember = async (user: Session["user"]) => { + try { + await api.get( + Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, user.discordUserId), + ); + return true; + } catch { + return false; + } +}; + +export async function isDiscordVIP(discordUserId: string) { + const guildMember = (await api.get( + Routes.guildMember(DISCORD.KNIGHTHACKS_GUILD, discordUserId), + )) as APIGuildMember; + return guildMember.roles.includes(DISCORD.VIP_ROLE); +} + +export async function log({ + title, + message, + color, + userId, +}: { + title: string; + message: string; + color: "tk_blue" | "blade_purple" | "uhoh_red" | "success_green"; + userId: string; +}) { + await api.post(Routes.channelMessages(DISCORD.LOG_CHANNEL), { + body: { + embeds: [ + { + title: title, + description: message + `\n\nUser: <@${userId}>`.toString(), + color: { + tk_blue: 0x1a73e8, + blade_purple: 0xcca4f4, + uhoh_red: 0xff0000, + success_green: 0x00ff00, + }[color], + footer: { + text: new Date().toLocaleString(), + }, + }, + ], + }, + }); +} diff --git a/packages/utils/src/env.ts b/packages/utils/src/env.ts new file mode 100644 index 000000000..df5c6802e --- /dev/null +++ b/packages/utils/src/env.ts @@ -0,0 +1,14 @@ +import { createEnv } from "@t3-oss/env-nextjs"; // TODO: look into not using the nextjs version +import { z } from "zod"; + +export const env = createEnv({ + server: { + DISCORD_BOT_TOKEN: z.string(), + STRIPE_SECRET_KEY: z.string(), + GOOGLE_CLIENT_EMAIL: z.string(), + GOOGLE_PRIVATE_KEY_B64: z.string(), + }, + experimental__runtimeEnv: {}, + skipValidation: + !!process.env.CI || process.env.npm_lifecycle_event === "lint", +}); diff --git a/packages/utils/src/events.ts b/packages/utils/src/events.ts new file mode 100644 index 000000000..f910ebe9f --- /dev/null +++ b/packages/utils/src/events.ts @@ -0,0 +1,34 @@ +import type { EVENTS } from "@forge/consts"; + +/** + * Gets the Tailwind CSS color classes for an event tag. + * + * @param {EVENTS.EventTagsColor} tag - The event tag. + * @returns {string} Tailwind CSS classes for the tag color. + * + * @example + * getTagColor("GBM") // "bg-blue-100 text-blue-800" + */ +export const getTagColor = (tag: EVENTS.EventTagsColor) => { + const colors: Record = { + GBM: "bg-blue-100 text-blue-800", + Social: "bg-pink-100 text-pink-800", + Kickstart: "bg-green-100 text-green-800", + "Project Launch": "bg-purple-100 text-purple-800", + "Hello World": "bg-yellow-100 text-yellow-800", + Sponsorship: "bg-orange-100 text-orange-800", + "Tech Exploration": "bg-cyan-100 text-cyan-800", + "Class Support": "bg-indigo-100 text-indigo-800", + Workshop: "bg-teal-100 text-teal-800", + OPS: "bg-purple-100 text-purple-800", + Hackathon: "bg-violet-100 text-violet-800", + Collabs: "bg-red-100 text-red-800", + "Check-in": "bg-gray-100 text-gray-800", + Ceremony: "bg-amber-100 text-amber-800", + Merch: "bg-lime-100 text-lime-800", + Food: "bg-rose-100 text-rose-800", + "CAREER-FAIR": "bg-lime-100 text-lime-800", // change later + "RSO-FAIR": "bg-lime-100 text-lime-800", // change later + }; + return colors[tag]; +}; diff --git a/apps/blade/src/app/_components/forms/utils.ts b/packages/utils/src/forms.client.ts similarity index 99% rename from apps/blade/src/app/_components/forms/utils.ts rename to packages/utils/src/forms.client.ts index 72733f928..5de9c8615 100644 --- a/apps/blade/src/app/_components/forms/utils.ts +++ b/packages/utils/src/forms.client.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import z from "zod"; import type { FORMS } from "@forge/consts"; diff --git a/packages/utils/src/forms.ts b/packages/utils/src/forms.ts new file mode 100644 index 000000000..68e67dd5d --- /dev/null +++ b/packages/utils/src/forms.ts @@ -0,0 +1,250 @@ +import "server-only"; + +import type { JSONSchema7 } from "json-schema"; +import { TRPCError } from "@trpc/server"; +import z from "zod"; + +import type { Form } from "@forge/db/schemas/knight-hacks"; +import { FORMS, MINIO } from "@forge/consts"; +import { db } from "@forge/db/client"; +import { FormSchemaSchema, FormsSchemas } from "@forge/db/schemas/knight-hacks"; + +type OptionalSchema = + | { success: true; schema: JSONSchema7 } + | { success: false; msg: string }; + +function createJsonSchemaValidator({ + optional, + type, + options, + optionsConst, + min, + max, + allowOther, +}: FORMS.ValidatorOptions): OptionalSchema { + const schema: JSONSchema7 = {}; + + const resolvedOptions = optionsConst + ? [...FORMS.getDropdownOptionsFromConst(optionsConst)] + : options; + + switch (type) { + case "SHORT_ANSWER": + case "PARAGRAPH": + schema.type = "string"; + if (max === undefined) { + schema.maxLength = type === "SHORT_ANSWER" ? 150 : 750; + } + break; + case "EMAIL": + schema.type = "string"; + schema.format = "email"; + break; + case "PHONE": + schema.type = "string"; + schema.pattern = "^\\+?\\d{7,15}$"; + break; + case "DATE": + schema.type = "string"; + schema.format = "date"; + break; + case "TIME": + schema.type = "string"; + schema.pattern = "^([01]\\d|2[0-3]):([0-5]\\d)$"; + break; + case "NUMBER": + case "LINEAR_SCALE": + schema.type = "number"; + break; + case "MULTIPLE_CHOICE": + case "DROPDOWN": + if (!resolvedOptions?.length) + return { + success: false, + msg: "Options are required for multiple choice / dropdown", + }; + schema.type = "string"; + if (!allowOther) { + schema.enum = resolvedOptions; + } + break; + case "CHECKBOXES": + if (!resolvedOptions?.length) + return { success: false, msg: "Options required for checkboxes" }; + schema.type = "array"; + if (allowOther) { + schema.items = { type: "string" }; + } else { + schema.items = { type: "string", enum: resolvedOptions }; + } + break; + case "FILE_UPLOAD": + schema.type = "string"; + break; + case "BOOLEAN": + schema.type = "boolean"; + break; + case "LINK": + schema.type = "string"; + schema.format = "uri"; + break; + default: + schema.type = "string"; + } + + if (min !== undefined) { + if (schema.type === "string") schema.minLength = min; + if (schema.type === "array") schema.minItems = min; + if (schema.type === "number") schema.minimum = min; + } else { + if (schema.type === "array" && !optional) schema.minItems = 1; + } + + if (max !== undefined) { + if (schema.type === "string") { + // Explicit max value overrides any defaults + schema.maxLength = max; + } + if (schema.type === "array") schema.maxItems = max; + if (schema.type === "number") schema.maximum = max; + } + + return { success: true, schema }; +} + +export function generateJsonSchema(form: FORMS.FormType): OptionalSchema { + const schema: JSONSchema7 = { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }; + + const properties: Record = {}; + const required: string[] = []; + + for (const formQuestion of form.questions) { + const { question, optional, ...rest } = formQuestion; + const convert = createJsonSchemaValidator({ optional, ...rest }); + if (convert.success) properties[question] = convert.schema; + else return convert; + + if (!optional) { + required.push(question); + } + } + + schema.properties = properties; + if (required.length > 0) { + schema.required = required; + } + + return { success: true, schema }; +} + +// Helper to regenerate presigned URLs for media +export async function regenerateMediaUrls( + instructions: FORMS.FormType["instructions"], + minioClient: { + presignedGetObject: ( + bucket: string, + objectName: string, + expiry: number, + ) => Promise; + }, +) { + if (!instructions) return []; + const updatedQuestions = await Promise.all( + instructions.map(async (i) => { + const updated = { ...i }; + + // Regenerate image URL if objectName exists + if ("imageObjectName" in i && i.imageObjectName) { + try { + updated.imageUrl = await minioClient.presignedGetObject( + MINIO.FORM_ASSETS_BUCKET_NAME, + i.imageObjectName, + MINIO.PRESIGNED_URL_EXPIRY, + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error("Failed to regenerate image URL:", e); + } + } + + // Regenerate video URL if objectName exists + if ("videoObjectName" in i && i.videoObjectName) { + try { + updated.videoUrl = await minioClient.presignedGetObject( + MINIO.FORM_ASSETS_BUCKET_NAME, + i.videoObjectName, + MINIO.PRESIGNED_URL_EXPIRY, + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error("Failed to regenerate video URL:", e); + } + } + + return updated; + }), + ); + + return updatedQuestions; +} + +export const CreateFormSchema = FormSchemaSchema.omit({ + id: true, + name: true, + slugName: true, + createdAt: true, + formData: true, + formValidatorJson: true, +}) + .extend({ formData: FORMS.FormSchemaValidator }) + .extend({ section: z.string().optional() }); + +type CreateFormType = z.infer; + +export async function createForm(input: CreateFormType): Promise { + const jsonSchema = generateJsonSchema(input.formData); + + const slug_name = input.formData.name.toLowerCase().replaceAll(" ", "-"); + + if (!jsonSchema.success) { + throw new TRPCError({ + message: jsonSchema.msg, + code: "BAD_REQUEST", + }); + } + + let sectionId: string | null = null; + const sectionName = input.section ?? "General"; + + if (sectionName !== "General") { + const section = await db.query.FormSections.findFirst({ + where: (t, { eq }) => eq(t.name, sectionName), + }); + sectionId = section?.id ?? null; + } + + const [form] = await db + .insert(FormsSchemas) + .values({ + ...input, + name: input.formData.name, + slugName: slug_name, + formValidatorJson: jsonSchema.schema, + sectionId, + }) + .returning(); + + if (!form) { + throw new TRPCError({ + message: "Could not create form", + code: "INTERNAL_SERVER_ERROR", + }); + } + + return form; +} diff --git a/packages/utils/src/google.ts b/packages/utils/src/google.ts new file mode 100644 index 000000000..51f0b1b0e --- /dev/null +++ b/packages/utils/src/google.ts @@ -0,0 +1,34 @@ +import "server-only"; + +import { google } from "googleapis"; + +import { EVENTS } from "@forge/consts"; + +import { env } from "./env"; + +const GOOGLE_PRIVATE_KEY = Buffer.from(env.GOOGLE_PRIVATE_KEY_B64, "base64") + .toString("utf-8") + .replace(/\\n/g, "\n"); + +const gapiCalendar = "https://www.googleapis.com/auth/calendar"; +const gapiGmailSend = "https://www.googleapis.com/auth/gmail.send"; +const gapiGmailSettingsSharing = + "https://www.googleapis.com/auth/gmail.settings.sharing"; + +const auth = new google.auth.JWT( + env.GOOGLE_CLIENT_EMAIL, + undefined, + GOOGLE_PRIVATE_KEY, + [gapiCalendar, gapiGmailSend, gapiGmailSettingsSharing], + EVENTS.GOOGLE_PERSONIFY_EMAIL as string, +); + +export const gmail = google.gmail({ + version: "v1", + auth: auth, +}); + +export const calendar = google.calendar({ + version: "v3", + auth: auth, +}); diff --git a/packages/utils/src/hackathons.ts b/packages/utils/src/hackathons.ts new file mode 100644 index 000000000..180e9e54e --- /dev/null +++ b/packages/utils/src/hackathons.ts @@ -0,0 +1,25 @@ +import type { HackerClass } from "@forge/db/schemas/knight-hacks"; + +/** + * Gets the team information for a hackathon class. + * + * @param {HackerClass} tag - The hacker class. + * @returns {object} Team information including name, color, and image URL. + * + * @example + * getClassTeam("Harbinger") // { team: "Monstrosity", teamColor: "#e03131", imgUrl: "/khviii/lenneth.jpg" } + */ +export const getClassTeam = (tag: HackerClass) => { + if (["Harbinger", "Alchemist", "Monstologist"].includes(tag)) { + return { + team: "Monstrosity", + teamColor: "#e03131", + imgUrl: "/khviii/lenneth.jpg", + }; + } + return { + team: "Humanity", + teamColor: "#228be6", + imgUrl: "/khviii/tkhero.jpg", + }; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 000000000..c805bd0e0 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,10 @@ +export * as events from "./events"; +export * as hackathons from "./hackathons"; +export { logger } from "./logger"; +export * as permissions from "./permissions"; +export * as time from "./time"; +export * as trpc from "./trpc"; + +// Note: stripe is server-only and should be imported from @forge/utils/stripe + +export const name = "utils"; diff --git a/packages/utils/src/logger.ts b/packages/utils/src/logger.ts new file mode 100644 index 000000000..2014ec08f --- /dev/null +++ b/packages/utils/src/logger.ts @@ -0,0 +1,17 @@ +// +// Right now logger will just export console. This lets us pretend that we have +// logging setup, when in reality we don't. But in the future, we can just do +// +// export { createLogger, COLORS }; +// +// ... +// +// const logger = createLogger({ color: COLORS.orange, "trpc/member/mutate" }) +// +// ... +// +// logger.error("Something happened!!!", err) +// + +// TODO: implement a real logger +export const logger = console; diff --git a/packages/utils/src/permissions.server.ts b/packages/utils/src/permissions.server.ts new file mode 100644 index 000000000..0567c5fbe --- /dev/null +++ b/packages/utils/src/permissions.server.ts @@ -0,0 +1,61 @@ +import "server-only"; + +import { cookies } from "next/headers"; +import { and, eq, gt } from "drizzle-orm"; + +import { db } from "@forge/db/client"; +import { JudgeSession } from "@forge/db/schemas/auth"; + +import { logger } from "./logger"; + +/** + * Server-only function to check if the current user is a judge admin. + * Uses cookies() from next/headers, so this can only be used in Server Components or Server Actions. + */ +export const isJudgeAdmin = async () => { + try { + const token = cookies().get("sessionToken")?.value; + if (!token) return false; + + const now = new Date(); + const rows = await db + .select({ sessionToken: JudgeSession.sessionToken }) + .from(JudgeSession) + .where( + and( + eq(JudgeSession.sessionToken, token), + gt(JudgeSession.expires, now), + ), + ) + .limit(1); + + return rows.length > 0; + } catch (err) { + logger.error("isJudgeAdmin DB check error:", err); + return false; + } +}; + +/** + * Server-only function to get judge session from cookie. + * Uses cookies() from next/headers, so this can only be used in Server Components or Server Actions. + */ +export const getJudgeSessionFromCookie = async () => { + const token = cookies().get("sessionToken")?.value; + if (!token) return null; + + const now = new Date(); + const rows = await db + .select({ + sessionToken: JudgeSession.sessionToken, + roomName: JudgeSession.roomName, + expires: JudgeSession.expires, + }) + .from(JudgeSession) + .where( + and(eq(JudgeSession.sessionToken, token), gt(JudgeSession.expires, now)), + ) + .limit(1); + + return rows[0] ?? null; +}; diff --git a/packages/utils/src/permissions.ts b/packages/utils/src/permissions.ts new file mode 100644 index 000000000..a4c7a68a7 --- /dev/null +++ b/packages/utils/src/permissions.ts @@ -0,0 +1,56 @@ +import { TRPCError } from "@trpc/server"; + +import { PERMISSIONS } from "@forge/consts"; + +export const hasPermission = ( + userPermissions: string, + permission: PERMISSIONS.PermissionIndex, +): boolean => { + const permissionBit = userPermissions[permission]; + return permissionBit === "1"; +}; + +// Mock tRPC context for type-safety +interface Context { + session: { + permissions: Record; + }; +} + +export const controlPerms = { + // Returns true if the user has any required permission OR has isOfficer role + or: (perms: PERMISSIONS.PermissionKey[], ctx: Context) => { + // first check if user has IS_OFFICER + if (ctx.session.permissions.IS_OFFICER) return true; + + let flag = false; + for (const p of perms) if (ctx.session.permissions[p]) flag = true; + if (!flag) throw new TRPCError({ code: "UNAUTHORIZED" }); + return true; + }, + + // Returns true only if the user has ALL required permissions + and: (perms: PERMISSIONS.PermissionKey[], ctx: Context) => { + // first check if user has IS_OFFICER + if (ctx.session.permissions.IS_OFFICER) return true; + + for (const p of perms) + if (!ctx.session.permissions[p]) + throw new TRPCError({ code: "UNAUTHORIZED" }); + + return true; + }, +}; + +export function getPermsAsList(perms: string) { + const list = []; + const permKeys = Object.keys(PERMISSIONS.PERMISSIONS); + for (let i = 0; i < perms.length; i++) { + const permKey = permKeys.at(i); + if (perms[i] == "1" && permKey) { + const permissionData = PERMISSIONS.PERMISSION_DATA[permKey]; + if (permissionData) list.push(permissionData.name); + } + } + return list; +} diff --git a/packages/utils/src/stripe.ts b/packages/utils/src/stripe.ts new file mode 100644 index 000000000..90f8fa73a --- /dev/null +++ b/packages/utils/src/stripe.ts @@ -0,0 +1,7 @@ +import "server-only"; + +import Stripe from "stripe"; + +import { env } from "./env"; + +export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { typescript: true }); diff --git a/packages/utils/src/time.ts b/packages/utils/src/time.ts new file mode 100644 index 000000000..4620a3929 --- /dev/null +++ b/packages/utils/src/time.ts @@ -0,0 +1,108 @@ +/** + * Formats a given date into a 12-hour time string with AM/PM. + * + * @param {Date} date - The date object to format. + * @returns {string} The formatted time in "h:mm am/pm" format. + * + * @example + * const date = new Date('2023-02-19T14:30:00'); + * console.log(formatHourTime(date)); // "2:30pm" + */ +export function formatHourTime(date: Date): string { + const hours = date.getHours(); + const minutes = date.getMinutes(); + const ampm = hours >= 12 ? "pm" : "am"; + + // Convert hours to 12-hour format + const formattedHours = hours % 12 || 12; + // Pad minutes with leading zero if necessary + const formattedMinutes = minutes.toString().padStart(2, "0"); + + return `${formattedHours}:${formattedMinutes}${ampm}`; +} + +/** + * Formats a date range (start and end date) into a readable time range string. + * + * @param {Date} startDate - The start date of the range. + * @param {Date} endDate - The end date of the range. + * @returns {string} The formatted time range in "h:mm am/pm - h:mm am/pm" format. + * + * @example + * const start = new Date('2023-02-19T09:00:00'); + * const end = new Date('2023-02-19T17:00:00'); + * console.log(formatTimeRange(start, end)); // "9:00am - 5:00pm" + */ +export const formatTimeRange = (startDate: Date, endDate: Date) => { + const start = formatHourTime(startDate); + const end = formatHourTime(endDate); + return `${start} - ${end}`; +}; + +/** + * Formats a date range (start and end date) into a readable date range string. + * + * @param {Date} startDate - The start date of the range. + * @param {Date} endDate - The end date of the range. + * @returns {string} The formatted date range in "Jan 1 - Jan 15, 2024" format. + * + * @example + * const start = new Date('2024-01-01'); + * const end = new Date('2024-01-15'); + * console.log(formatDateRange(start, end)); // "Jan 1 - Jan 15, 2024" + */ +export const formatDateRange = (startDate: Date, endDate: Date) => { + const start = new Date(startDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + const end = new Date(endDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + return `${start} - ${end}`; +}; + +/** + * Formats a date into a readable date-time string with timezone adjustment. + * Creates a new Date object adjusted by 1 day and formats it. + * + * @param {Date} date - The date object to format. + * @returns {string} The formatted date-time in "MMM D, YYYY, h:mm AM/PM" format. + * + * @example + * const date = new Date('2023-02-19T14:30:00'); + * console.log(formatDateTime(date)); // "Feb 20, 2023, 2:30 PM" + */ +export const formatDateTime = (date: Date) => { + // Create a new Date object 5 hours behind the original + const adjustedDate = new Date(date.getTime()); + adjustedDate.setDate(adjustedDate.getDate() + 1); + + return adjustedDate.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +}; + +/** + * Formats a date into a simple date string with timezone adjustment. + * Creates a new Date object adjusted by 1 day and formats it. + * + * @param {string | Date} start_datetime - The date to format. + * @returns {string} The formatted date string. + * + * @example + * const date = new Date('2023-02-19'); + * console.log(getFormattedDate(date)); // "2/20/2023" + */ +export const getFormattedDate = (start_datetime: string | Date) => { + const date = new Date(start_datetime); + date.setDate(date.getDate() + 1); + return date.toLocaleDateString(); +}; diff --git a/packages/utils/src/trpc.ts b/packages/utils/src/trpc.ts new file mode 100644 index 000000000..aae566008 --- /dev/null +++ b/packages/utils/src/trpc.ts @@ -0,0 +1,55 @@ +import type { AnyTRPCProcedure, AnyTRPCRouter } from "@trpc/server"; +import type { z } from "zod"; + +/** + * Metadata for a tRPC procedure. + */ +export interface ProcedureMeta { + inputSchema: string[]; + route: string; +} + +interface ProcedureMetaOriginal { + id: string; + /* eslint-disable @typescript-eslint/no-explicit-any */ + inputSchema: z.ZodObject; +} + +function hasSchemaMeta(meta: unknown): meta is ProcedureMetaOriginal { + return ( + typeof meta === "object" && + meta !== null && + "id" in meta && + "inputSchema" in meta + ); +} + +/** + * Extracts procedure metadata from a tRPC router. + * Useful for form connections and other dynamic tRPC usage. + * + * @param {AnyTRPCRouter} router - The tRPC router to extract procedures from. + * @returns {Record} A record of procedure IDs to their metadata. + * + * @example + * const procedures = extractProcedures(appRouter); + * // { "procedureId": { inputSchema: ["field1", "field2"], route: "router.procedure" } } + */ +export function extractProcedures(router: AnyTRPCRouter) { + const procedures: Record = {}; + + /* eslint-disable @typescript-eslint/no-unsafe-argument */ + for (const [procKey, proc] of Object.entries(router._def.procedures)) { + const procTyped = proc as AnyTRPCProcedure; + + const meta = procTyped._def.meta; + if (!hasSchemaMeta(meta)) continue; + + procedures[meta.id] = { + inputSchema: Object.keys(meta.inputSchema.shape), + route: procKey, + }; + } + + return procedures; +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 000000000..a8e81d09e --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@forge/tsconfig/internal-package.json", + "compilerOptions": {}, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49c580f18..26a88c37b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: '@forge/ui': specifier: workspace:* version: link:../../packages/ui + '@forge/utils': + specifier: workspace:* + version: link:../../packages/utils '@forge/validators': specifier: workspace:* version: link:../../packages/validators @@ -347,6 +350,9 @@ importers: '@forge/ui': specifier: workspace:* version: link:../../packages/ui + '@forge/utils': + specifier: workspace:* + version: link:../../packages/utils '@gsap/react': specifier: ^2.1.2 version: 2.1.2(gsap@3.14.2)(react@18.3.1) @@ -426,6 +432,9 @@ importers: '@forge/db': specifier: workspace:* version: link:../../packages/db + '@forge/utils': + specifier: workspace:* + version: link:../../packages/utils '@forge/validators': specifier: workspace:* version: link:../../packages/validators @@ -687,6 +696,9 @@ importers: '@forge/db': specifier: workspace:* version: link:../../packages/db + '@forge/utils': + specifier: workspace:* + version: link:../../packages/utils '@forge/validators': specifier: workspace:* version: link:../../packages/validators @@ -766,6 +778,9 @@ importers: '@forge/email': specifier: workspace:^ version: link:../email + '@forge/utils': + specifier: workspace:* + version: link:../utils '@forge/validators': specifier: workspace:* version: link:../validators @@ -983,6 +998,9 @@ importers: packages/email: dependencies: + '@forge/utils': + specifier: workspace:* + version: link:../utils '@maloma/listmonk': specifier: ^1.0.1 version: 1.0.1 @@ -1154,6 +1172,58 @@ importers: specifier: 'catalog:' version: 3.25.76 + packages/utils: + dependencies: + '@discordjs/rest': + specifier: ^2.4.0 + version: 2.6.0 + '@t3-oss/env-nextjs': + specifier: ^0.11.1 + version: 0.11.1(typescript@5.7.3)(zod@3.25.76) + discord-api-types: + specifier: ^0.37.113 + version: 0.37.120 + googleapis: + specifier: ^144.0.0 + version: 144.0.0 + server-only: + specifier: ^0.0.1 + version: 0.0.1 + devDependencies: + '@forge/auth': + specifier: workspace:* + version: link:../auth + '@forge/consts': + specifier: workspace:* + version: link:../consts + '@forge/db': + specifier: workspace:* + version: link:../db + '@forge/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@forge/prettier-config': + specifier: workspace:* + version: link:../../tooling/prettier + '@forge/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@trpc/server': + specifier: 'catalog:' + version: 11.9.0(typescript@5.7.3) + eslint: + specifier: 'catalog:' + version: 9.39.2(jiti@2.6.1) + prettier: + specifier: 'catalog:' + version: 3.8.1 + typescript: + specifier: 'catalog:' + version: 5.7.3 + zod: + specifier: 'catalog:' + version: 3.25.76 + packages/validators: dependencies: minimatch: @@ -4767,6 +4837,7 @@ packages: basic-ftp@5.1.0: resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade better-auth@1.4.18: resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==} diff --git a/tooling/eslint/base.js b/tooling/eslint/base.js index c37b61194..0c975eff0 100644 --- a/tooling/eslint/base.js +++ b/tooling/eslint/base.js @@ -75,7 +75,7 @@ export default tseslint.config( ], "@typescript-eslint/no-non-null-assertion": "error", "import/consistent-type-specifier-style": ["error", "prefer-top-level"], - "no-console": "warn", + "no-console": "error", }, }, {