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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"project": true
},
"plugins": ["@typescript-eslint"],
"ignorePatterns": ["scripts/**"],
"extends": [
"next",
"next/core-web-vitals",
Expand Down
216 changes: 108 additions & 108 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"format:check": "prettier --check .",
"format": "prettier --write . --list-different",
"db:push": "prisma db push",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- AlterTable
ALTER TABLE "UserMetadata" ADD COLUMN "avatarUrl" TEXT NOT NULL DEFAULT '',
ADD COLUMN "bio" TEXT NOT NULL DEFAULT 'Enter edit mode to update your bio',
ADD COLUMN "birthday" DATE,
ADD COLUMN "department" TEXT NOT NULL DEFAULT '',
ADD COLUMN "jobTitle" TEXT NOT NULL DEFAULT '',
ADD COLUMN "location" TEXT NOT NULL DEFAULT '',
ADD COLUMN "mobile" TEXT NOT NULL DEFAULT '',
ADD COLUMN "workNumber" TEXT NOT NULL DEFAULT '';

-- CreateTable
CREATE TABLE "Report" (
"id" UUID NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"userId" UUID,
"pagePath" TEXT NOT NULL,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "CareerDevArticles" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"title" TEXT NOT NULL,
"author" TEXT NOT NULL,
"blurb" TEXT NOT NULL,
"body" TEXT,
"date" DATE NOT NULL,
"starttime" TIME(6) NOT NULL,
"endtime" TIME(6) NOT NULL,
"location" TEXT NOT NULL,
"imageurl" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "CareerDevArticles_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "Report_userId_idx" ON "Report"("userId");

-- CreateIndex
CREATE INDEX "Report_createdAt_idx" ON "Report"("createdAt");

-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserMetadata"("id") ON DELETE SET NULL ON UPDATE CASCADE;
17 changes: 17 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,28 @@ model UserMetadata {
employeeLastName String?
TimeOffRequests TimeOffRequest[]
Files Files[]
Reports Report[]

evaluationsReceivedMetadata EmployeeEvaluationMetadata[] @relation("ReviewedEmployeeMetadata")
evaluationsSubmittedMetadata EmployeeEvaluationMetadata[] @relation("SubmitterMetadata")
}

model Report {
id String @id @default(uuid()) @db.Uuid
title String
description String
userId String? @db.Uuid
pagePath String
// metadata may include other info such as browser, pageUrl
metadata Json?
createdAt DateTime @default(now())

user UserMetadata? @relation(fields: [userId], references: [id], onDelete: SetNull)

@@index([userId])
@@index([createdAt])
}

enum FileTypes {
AVATAR
DOCUMENT
Expand Down
49 changes: 49 additions & 0 deletions src/app/api/bug-report/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { getUser } from "@/utils/supabase/server";

type CreateReportBody = {
title?: string;
description?: string;
pagePath?: string;
metadata?: Prisma.InputJsonValue | null;
};

export async function POST(req: Request) {
try {
const body = (await req.json()) as CreateReportBody;
// trim incoming report body
const title = body.title?.trim();
const description = body.description?.trim();
const pagePath = body.pagePath?.trim();

if (!title || !description || !pagePath) {
return NextResponse.json(
{ error: "title, description, and pagePath are required" },
{ status: 400 },
);
}

const user = await getUser();

if (!user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
// create new report in db
const createdReport = await prisma.report.create({
data: {
title,
description,
pagePath,
userId: user.id,
metadata: body.metadata ?? Prisma.JsonNull,
},
});

return NextResponse.json(createdReport, { status: 201 });
} catch (err) {
console.error("/api/bug-report POST error", err);
return NextResponse.json({ error: "Failed to create report" }, { status: 500 });
}
}
2 changes: 1 addition & 1 deletion src/app/api/documents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export async function getSignedUrlForFileId(
where: { id: fileId },
});

if (!file || file.type !== FileTypes.DOCUMENT || file.bucket !== DOCUMENTS_BUCKET) {
if (file?.type !== FileTypes.DOCUMENT || file.bucket !== DOCUMENTS_BUCKET) {
throw new DocumentAccessError("File not found", 404);
}
// check permission for viewing
Expand Down
5 changes: 2 additions & 3 deletions src/app/api/documents/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,7 @@ export async function PATCH(req: Request) {

const existingRecord = await prisma.files.findUnique({ where: { id: fileId } });
if (
!existingRecord ||
existingRecord.type !== FileTypes.DOCUMENT ||
existingRecord?.type !== FileTypes.DOCUMENT ||
existingRecord.bucket !== DOCUMENTS_BUCKET
) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
Expand Down Expand Up @@ -462,7 +461,7 @@ export async function DELETE(req: Request) {
}

const file = await prisma.files.findUnique({ where: { id: fileId } });
if (!file || file.type !== FileTypes.DOCUMENT || file.bucket !== DOCUMENTS_BUCKET) {
if (file?.type !== FileTypes.DOCUMENT || file.bucket !== DOCUMENTS_BUCKET) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}

Expand Down
201 changes: 201 additions & 0 deletions src/components/BugReportModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"use client";

import { useEffect, useState } from "react";

type BugReportModalProps = {
isOpen: boolean;
onClose: () => void;
};

const EMPTY_FORM = {
title: "",
description: "",
};

export default function BugReportModal({ isOpen, onClose }: BugReportModalProps) {
const [formData, setFormData] = useState({ ...EMPTY_FORM });
const [isSubmitting, setIsSubmitting] = useState(false); // prevent double clicking
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);

// handle description section text input
useEffect(() => {
if (!isOpen) return;

setFormData({ ...EMPTY_FORM });
setIsSubmitting(false);
setError(null);
setSuccessMessage(null);
}, [isOpen]);

useEffect(() => {
if (!isOpen) return;

// escape key exit
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();

setError(null);
setSuccessMessage(null);
setIsSubmitting(true);

try {
const response = await fetch("/api/bug-report", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: formData.title,
description: formData.description,
pagePath: window.location.pathname,
metadata: {
pageUrl: window.location.href,
userAgent: navigator.userAgent,
},
}),
});

// handle error message
const contentType = response.headers.get("content-type");
let errorMessage = `Request failed with status ${response.status}.`;

if (contentType?.includes("application/json")) {
const data = (await response.json()) as { error?: string };
errorMessage = data.error ?? errorMessage;
} else {
const text = await response.text();

if (response.status === 404) {
errorMessage = "API route not found. Check the fetch URL.";
} else if (response.status === 401) {
errorMessage = "You are not signed in.";
} else if (text.includes("<!DOCTYPE") || text.includes("<html")) {
errorMessage = "Server returned an HTML page instead of JSON. Check the API route.";
}
}

if (!response.ok) {
throw new Error(errorMessage);
}

setSuccessMessage("Bug report submitted successfully.");
setFormData({ ...EMPTY_FORM });
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsSubmitting(false);
}
};

if (!isOpen) return null;

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="bug-report-modal-title"
onClick={onClose}
>
<div
className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl bg-base-100 text-base-content shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between border-b border-base-300 p-4">
<h2 id="bug-report-modal-title" className="text-lg font-semibold">
Report a Bug
</h2>
<button
type="button"
className="rounded-full p-1 text-base-content/60 hover:bg-base-200"
onClick={onClose}
aria-label="Close"
>
</button>
</div>

<form onSubmit={handleSubmit} className="space-y-4 p-4">
<p className="text-sm text-base-content/70">
Describe the issue you found and where it happened.
</p>
{/* success/error message */}
{successMessage ? (
<div className="rounded-lg border border-success/30 bg-success/10 p-3 text-sm text-success">
{successMessage}
</div>
) : null}
{error ? (
<div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error">
{error}
</div>
) : null}
{/* for title */}
<div className="form-control">
<label htmlFor="bug-report-title" className="label">
<span className="label-text font-semibold">Title</span>
</label>
<input
id="bug-report-title"
type="text"
className="input-bordered input w-full"
placeholder="Short summary of the bug"
value={formData.title}
onChange={(event) =>
setFormData((current) => ({
...current,
title: event.target.value,
}))
}
required
/>
</div>
{/* for description */}
<div className="form-control">
<label htmlFor="bug-report-description" className="label">
<span className="label-text font-semibold">Description</span>
</label>
<textarea
id="bug-report-description"
className="textarea-bordered textarea min-h-32 w-full"
placeholder="What happened? What did you expect to happen?"
value={formData.description}
onChange={(event) =>
setFormData((current) => ({
...current,
description: event.target.value,
}))
}
required
/>
</div>
{/* cancel, submit button*/}
<div className="modal-action mt-2">
<button
type="button"
className="btn btn-ghost"
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit Report"}
</button>
</div>
</form>
</div>
</div>
);
}
Loading
Loading