diff --git a/docker/init-scripts/01-schema.sql b/docker/init-scripts/01-schema.sql index 70d10a77..03bfc59a 100644 --- a/docker/init-scripts/01-schema.sql +++ b/docker/init-scripts/01-schema.sql @@ -32,7 +32,8 @@ CREATE TYPE public."roleType" AS ENUM ( 'USER', 'STAFF', 'ADMIN', - 'CORPORATE' + 'CORPORATE', + 'SUPER_ADMIN' ); CREATE TYPE public."staffAttendanceType" AS ENUM ( diff --git a/src/database.ts b/src/database.ts index 5d495647..766e800b 100644 --- a/src/database.ts +++ b/src/database.ts @@ -78,13 +78,6 @@ export type EventType = Enums<"eventType">; export type StaffAttendanceType = Enums<"staffAttendanceType">; export type ShiftRoleType = Enums<"shiftRoleType">; -export const RoleTypes: Record = { - USER: "USER", - STAFF: "STAFF", - ADMIN: "ADMIN", - CORPORATE: "CORPORATE", -}; - export const CommitteeTypes: Record = { CONTENT: "CONTENT", CORPORATE: "CORPORATE", diff --git a/src/database.types.ts b/src/database.types.ts index dc6b1b0a..c9752d3b 100644 --- a/src/database.types.ts +++ b/src/database.types.ts @@ -345,6 +345,7 @@ export type Database = { name: string; points: number; startTime: string; + tags: string[]; }; Insert: { attendanceCount?: number; @@ -359,6 +360,7 @@ export type Database = { name: string; points: number; startTime: string; + tags?: string[]; }; Update: { attendanceCount?: number; @@ -373,6 +375,7 @@ export type Database = { name?: string; points?: number; startTime?: string; + tags?: string[]; }; Relationships: []; }; @@ -449,6 +452,29 @@ export type Database = { }, ]; }; + redemptions: { + Row: { + item: Database["public"]["Enums"]["tierType"]; + userId: string; + }; + Insert: { + item: Database["public"]["Enums"]["tierType"]; + userId: string; + }; + Update: { + item?: Database["public"]["Enums"]["tierType"]; + userId?: string; + }; + Relationships: [ + { + foreignKeyName: "redemptions_user_id_fkey"; + columns: ["userId"]; + isOneToOne: false; + referencedRelation: "authInfo"; + referencedColumns: ["userId"]; + }, + ]; + }; registrations: { Row: { allergies: string[]; @@ -523,6 +549,66 @@ export type Database = { }, ]; }; + shiftAssignments: { + Row: { + acknowledged: boolean; + shiftId: string; + staffEmail: string; + }; + Insert: { + acknowledged?: boolean; + shiftId: string; + staffEmail: string; + }; + Update: { + acknowledged?: boolean; + shiftId?: string; + staffEmail?: string; + }; + Relationships: [ + { + foreignKeyName: "shiftAssignments_shiftId_fkey"; + columns: ["shiftId"]; + isOneToOne: false; + referencedRelation: "shifts"; + referencedColumns: ["shiftId"]; + }, + { + foreignKeyName: "shiftAssignments_staffEmail_fkey"; + columns: ["staffEmail"]; + isOneToOne: false; + referencedRelation: "staff"; + referencedColumns: ["email"]; + }, + ]; + }; + shifts: { + Row: { + endTime: string; + location: string; + name: string; + role: Database["public"]["Enums"]["shiftRoleType"]; + shiftId: string; + startTime: string; + }; + Insert: { + endTime: string; + location: string; + name: string; + role: Database["public"]["Enums"]["shiftRoleType"]; + shiftId?: string; + startTime: string; + }; + Update: { + endTime?: string; + location?: string; + name?: string; + role?: Database["public"]["Enums"]["shiftRoleType"]; + shiftId?: string; + startTime?: string; + }; + Relationships: []; + }; speakers: { Row: { bio: string; @@ -574,66 +660,6 @@ export type Database = { }; Relationships: []; }; - shifts: { - Row: { - shiftId: string; - name: string; - role: Database["public"]["Enums"]["shiftRoleType"]; - startTime: string; - endTime: string; - location: string; - }; - Insert: { - shiftId?: string; - name: string; - role: Database["public"]["Enums"]["shiftRoleType"]; - startTime: string; - endTime: string; - location: string; - }; - Update: { - shiftId?: string; - name?: string; - role?: Database["public"]["Enums"]["shiftRoleType"]; - startTime?: string; - endTime?: string; - location?: string; - }; - Relationships: []; - }; - shiftAssignments: { - Row: { - shiftId: string; - staffEmail: string; - acknowledged: boolean; - }; - Insert: { - shiftId: string; - staffEmail: string; - acknowledged?: boolean; - }; - Update: { - shiftId?: string; - staffEmail?: string; - acknowledged?: boolean; - }; - Relationships: [ - { - foreignKeyName: "shiftAssignments_shiftId_fkey"; - columns: ["shiftId"]; - isOneToOne: false; - referencedRelation: "shifts"; - referencedColumns: ["shiftId"]; - }, - { - foreignKeyName: "shiftAssignments_staffEmail_fkey"; - columns: ["staffEmail"]; - isOneToOne: false; - referencedRelation: "staff"; - referencedColumns: ["email"]; - }, - ]; - }; subscriptions: { Row: { userId: string; @@ -657,29 +683,6 @@ export type Database = { }, ]; }; - redemptions: { - Row: { - userId: string; - item: Database["public"]["Enums"]["tierType"]; - }; - Insert: { - userId: string; - item: Database["public"]["Enums"]["tierType"]; - }; - Update: { - userId?: string; - item?: Database["public"]["Enums"]["tierType"]; - }; - Relationships: [ - { - foreignKeyName: "redemptions_user_id_fkey"; - columns: ["userId"]; - isOneToOne: false; - referencedRelation: "authInfo"; - referencedColumns: ["userId"]; - }, - ]; - }; }; Views: { [_ in never]: never; @@ -698,14 +701,6 @@ export type Database = { }; }; Enums: { - shiftRoleType: - | "CLEAN_UP" - | "DINNER" - | "CHECK_IN" - | "SPEAKER_BUDDY" - | "SPONSOR_BUDDY" - | "DEV_ON_CALL" - | "CHAIR_ON_CALL"; committeeNames: | "CONTENT" | "CORPORATE" @@ -728,7 +723,15 @@ export type Database = { | "PINK" | "PURPLE" | "ORANGE"; - roleType: "USER" | "STAFF" | "ADMIN" | "CORPORATE"; + roleType: "USER" | "STAFF" | "ADMIN" | "CORPORATE" | "SUPER_ADMIN"; + shiftRoleType: + | "CLEAN_UP" + | "DINNER" + | "CHECK_IN" + | "SPEAKER_BUDDY" + | "SPONSOR_BUDDY" + | "DEV_ON_CALL" + | "CHAIR_ON_CALL"; staffAttendanceType: "PRESENT" | "EXCUSED" | "ABSENT"; tierType: "TIER1" | "TIER2" | "TIER3" | "TIER4"; }; @@ -881,6 +884,8 @@ export const Constants = { "MEALS", "CHECKIN", ], + iconColorType: ["BLUE", "RED", "GREEN", "PINK", "PURPLE", "ORANGE"], + roleType: ["USER", "STAFF", "ADMIN", "CORPORATE", "SUPER_ADMIN"], shiftRoleType: [ "CLEAN_UP", "DINNER", @@ -890,8 +895,6 @@ export const Constants = { "DEV_ON_CALL", "CHAIR_ON_CALL", ], - iconColorType: ["BLUE", "RED", "GREEN", "PINK", "PURPLE", "ORANGE"], - roleType: ["USER", "STAFF", "ADMIN", "CORPORATE"], staffAttendanceType: ["PRESENT", "EXCUSED", "ABSENT"], tierType: ["TIER1", "TIER2", "TIER3", "TIER4"], }, diff --git a/src/services/auth/auth-models.ts b/src/services/auth/auth-models.ts index 9663630e..ef36b48b 100644 --- a/src/services/auth/auth-models.ts +++ b/src/services/auth/auth-models.ts @@ -1,6 +1,12 @@ import { z } from "zod"; -export const Role = z.enum(["USER", "STAFF", "ADMIN", "CORPORATE"]); +export const Role = z.enum([ + "USER", + "STAFF", + "ADMIN", + "CORPORATE", + "SUPER_ADMIN", +]); export type Role = z.infer; export enum Platform { diff --git a/src/services/auth/auth-router.test.ts b/src/services/auth/auth-router.test.ts index 5fe89124..84bc8d8e 100644 --- a/src/services/auth/auth-router.test.ts +++ b/src/services/auth/auth-router.test.ts @@ -10,8 +10,9 @@ import { postAsAdmin, postAsStaff, putAsAdmin, - putAsStaff, TESTER, + delAsSuperAdmin, + putAsSuperAdmin, } from "../../../testing/testingTools"; import { AuthInfo, AuthRole } from "./auth-schema"; import { Platform, Role } from "./auth-models"; @@ -81,7 +82,7 @@ beforeEach(async () => { describe("DELETE /auth/", () => { it("should remove the requested role", async () => { - const res = await delAsAdmin("/auth/") + const res = await delAsSuperAdmin("/auth/") .send({ userId: OTHER_USER.userId, role: Role.Enum.STAFF, @@ -101,7 +102,7 @@ describe("DELETE /auth/", () => { }); it("should give the not found error when the user doesn't exist", async () => { - const res = await delAsAdmin("/auth/") + const res = await delAsSuperAdmin("/auth/") .send({ userId: "nonexistent", role: Role.Enum.STAFF, @@ -111,8 +112,8 @@ describe("DELETE /auth/", () => { expect(res.body).toHaveProperty("error", "UserNotFound"); }); - it("should require admin permissions", async () => { - const res = await delAsStaff("/auth/") + it("should require super admin permissions", async () => { + const res = await delAsAdmin("/auth/") .send({ userId: OTHER_USER.userId, role: Role.Enum.STAFF, @@ -125,7 +126,7 @@ describe("DELETE /auth/", () => { describe("PUT /auth/", () => { it("should add the requested role", async () => { - const res = await putAsAdmin("/auth/") + const res = await putAsSuperAdmin("/auth/") .send({ userId: OTHER_USER.userId, role: Role.Enum.ADMIN, @@ -146,7 +147,7 @@ describe("PUT /auth/", () => { }); it("should give the not found error if the user doesn't exist", async () => { - const res = await putAsAdmin("/auth/") + const res = await putAsSuperAdmin("/auth/") .send({ userId: "nonexistent", role: Role.Enum.ADMIN, @@ -156,8 +157,8 @@ describe("PUT /auth/", () => { expect(res.body).toHaveProperty("error", "UserNotFound"); }); - it("should require admin permissions", async () => { - const res = await putAsStaff("/auth/") + it("should require super admin permissions", async () => { + const res = await putAsAdmin("/auth/") .send({ userId: OTHER_USER.userId, role: Role.Enum.STAFF, diff --git a/src/services/auth/auth-router.ts b/src/services/auth/auth-router.ts index f39cd2af..f6c0a6a3 100644 --- a/src/services/auth/auth-router.ts +++ b/src/services/auth/auth-router.ts @@ -35,34 +35,38 @@ const oauthClients = { authRouter.use("/sponsor", authSponsorRouter); -// Remove role from userId (admin only endpoint) -authRouter.delete("/", RoleChecker([Role.Enum.ADMIN]), async (req, res) => { - // Validate request body using Zod schema - const { userId, role } = AuthRoleChangeRequest.parse(req.body); +// Remove role from userId (super admin only endpoint) +authRouter.delete( + "/", + RoleChecker([Role.Enum.SUPER_ADMIN]), + async (req, res) => { + // Validate request body using Zod schema + const { userId, role } = AuthRoleChangeRequest.parse(req.body); - const { data } = await SupabaseDB.AUTH_INFO.select("userId") - .eq("userId", userId) - .maybeSingle() - .throwOnError(); + const { data } = await SupabaseDB.AUTH_INFO.select("userId") + .eq("userId", userId) + .maybeSingle() + .throwOnError(); - if (!data) { - return res.status(StatusCodes.NOT_FOUND).json({ - error: "UserNotFound", - }); - } + if (!data) { + return res.status(StatusCodes.NOT_FOUND).json({ + error: "UserNotFound", + }); + } - const { data: deleted } = await SupabaseDB.AUTH_ROLES.delete() - .eq("userId", userId) - .eq("role", role) - .select() - .single() - .throwOnError(); + const { data: deleted } = await SupabaseDB.AUTH_ROLES.delete() + .eq("userId", userId) + .eq("role", role) + .select() + .single() + .throwOnError(); - return res.status(StatusCodes.OK).json(deleted); -}); + return res.status(StatusCodes.OK).json(deleted); + } +); -// Add role to userId (admin only endpoint) -authRouter.put("/", RoleChecker([Role.Enum.ADMIN]), async (req, res) => { +// Add role to userId (super admin only endpoint) +authRouter.put("/", RoleChecker([Role.Enum.SUPER_ADMIN]), async (req, res) => { const { userId, role } = AuthRoleChangeRequest.parse(req.body); const { data } = await SupabaseDB.AUTH_INFO.select("userId") diff --git a/src/services/notifications/notifications-router.test.ts b/src/services/notifications/notifications-router.test.ts index 4619d3e0..1ffd20c9 100644 --- a/src/services/notifications/notifications-router.test.ts +++ b/src/services/notifications/notifications-router.test.ts @@ -22,8 +22,6 @@ jest.mock("../../firebase", () => ({ }), })); -jest.setTimeout(100000); - function makeTestAttendee(overrides = {}) { return { userId: TESTER.userId, @@ -97,6 +95,10 @@ async function insertTestUser(overrides: InsertTestAttendeeOverrides = {}) { ]).throwOnError(); } +beforeEach(() => { + mockSend.mockReset(); +}); + describe("/notifications", () => { describe("POST /notifications/register", () => { it("should create a notification entry and subscribe to the allUsers topic", async () => { @@ -141,8 +143,11 @@ describe("/notifications", () => { }); describe("POST /notifications/topics/:topicName", () => { - it("should send a notification as an admin", async () => { - await post("/notifications/topics/event_123", Role.enum.ADMIN) + it("should send a notification as an super admin", async () => { + const res = await post( + "/notifications/topics/event_123", + Role.enum.SUPER_ADMIN + ) .send({ title: "Admin Test", body: "Admin Message" }) .expect(StatusCodes.OK); @@ -154,6 +159,23 @@ describe("/notifications", () => { body: "Admin Message", }, }); + + expect(res.body).toMatchObject({ + status: "success", + }); + }); + it("fails if the user is not a super admin", async () => { + const res = await post( + "/notifications/topics/event_123", + Role.enum.ADMIN + ) + .send({ title: "Admin Test", body: "Admin Message" }) + .expect(StatusCodes.FORBIDDEN); + + // Verify Firebase mock was not called + expect(mockSend).not.toHaveBeenCalled(); + + expect(res.body).toHaveProperty("error", "Forbidden"); }); }); diff --git a/src/services/notifications/notifications-router.ts b/src/services/notifications/notifications-router.ts index 3f999e98..11dcf98c 100644 --- a/src/services/notifications/notifications-router.ts +++ b/src/services/notifications/notifications-router.ts @@ -59,13 +59,13 @@ notificationsRouter.post( } ); -// Admins can send notifications to a specific topic +// Super admins can send notifications to a specific topic // parameter: the topicName that the admin is sending to // ^ Can get this from dropdown (will have a route to get all topics) // Request body: title, body. (title and body of the notification) notificationsRouter.post( "/topics/:topicName", - RoleChecker([Role.enum.ADMIN]), // for now thinking that only admins get to use this + RoleChecker([Role.enum.SUPER_ADMIN]), async (req, res) => { sendToTopicSchema.parse(req.body); // make sure it fits the validator diff --git a/src/services/subscription/subscription-router.test.ts b/src/services/subscription/subscription-router.test.ts index d54958cf..03b6982a 100644 --- a/src/services/subscription/subscription-router.test.ts +++ b/src/services/subscription/subscription-router.test.ts @@ -4,6 +4,7 @@ import { getAsAdmin, postAsAdmin, delAsAdmin, + postAsSuperAdmin, } from "../../../testing/testingTools"; import { StatusCodes } from "http-status-codes"; import { SupabaseDB } from "../../database"; @@ -187,23 +188,24 @@ describe("GET /subscription/", () => { }); describe("POST /subscription/send-email", () => { - it("should send an email to all subscribers of a list", async () => { - const mailingList = VALID_mailingList; - const emails = ["user1@test.com", "user2@test.com"]; + const mailingList = VALID_mailingList; + const emails = ["user1@test.com", "user2@test.com"]; + const emailPayload = { + mailingList: mailingList, + subject: "Test Subject", + htmlBody: "

Hello World

", + }; + beforeEach(async () => { // Set up subscription data await SupabaseDB.SUBSCRIPTIONS.insert([ { userId: USER_ID_1, mailingList: mailingList }, { userId: USER_ID_2, mailingList: mailingList }, ]).throwOnError(); + }); - const emailPayload = { - mailingList: mailingList, - subject: "Test Subject", - htmlBody: "

Hello World

", - }; - - await postAsAdmin("/subscription/send-email") + it("should send an email to all subscribers of a list", async () => { + const res = await postAsSuperAdmin("/subscription/send-email") .send(emailPayload) .expect(StatusCodes.OK); @@ -223,18 +225,32 @@ describe("POST /subscription/send-email", () => { // Verify that the send method was actually invoked expect(mockSESV2Send).toHaveBeenCalledTimes(1); + + expect(res.body).toEqual({ status: "success" }); + }); + + it("fails to send an email for non super-admins", async () => { + const res = await postAsAdmin("/subscription/send-email") + .send(emailPayload) + .expect(StatusCodes.FORBIDDEN); + + expect(mockSendEmailCommand).not.toHaveBeenCalled(); + + // Verify that the send method was actually invoked + expect(mockSESV2Send).not.toHaveBeenCalled(); + + expect(res.body).toMatchObject({ error: "Forbidden" }); }); }); describe("POST /subscription/send-email/single", () => { + const emailPayload = { + email: "ritam@test.com", + subject: "Single Email Test", + htmlBody: "

Single Email Body

", + }; it("should send an email to a single specified email address", async () => { - const emailPayload = { - email: "ritam@test.com", - subject: "Single Email Test", - htmlBody: "

Single Email Body

", - }; - - await postAsAdmin("/subscription/send-email/single") + const res = await postAsSuperAdmin("/subscription/send-email/single") .send(emailPayload) .expect(StatusCodes.OK); @@ -253,6 +269,19 @@ describe("POST /subscription/send-email/single", () => { // Verify that the send method was actually invoked expect(mockSESV2Send).toHaveBeenCalledTimes(1); + + expect(res.body).toEqual({ status: "success" }); + }); + + it("fails to send for non super-admin", async () => { + const res = await postAsAdmin("/subscription/send-email/single") + .send(emailPayload) + .expect(StatusCodes.FORBIDDEN); + + expect(mockSendEmailCommand).not.toHaveBeenCalled(); + expect(mockSESV2Send).not.toHaveBeenCalled(); + + expect(res.body).toMatchObject({ error: "Forbidden" }); }); }); diff --git a/src/services/subscription/subscription-router.ts b/src/services/subscription/subscription-router.ts index fcc7fd1d..e50fb8a5 100644 --- a/src/services/subscription/subscription-router.ts +++ b/src/services/subscription/subscription-router.ts @@ -87,7 +87,7 @@ subscriptionRouter.get( // API body: {String} mailingList The list to send the email to, {String} subject The subject line of the email, {String} htmlBody The HTML content of the email. subscriptionRouter.post( "/send-email", - RoleChecker([Role.Enum.ADMIN]), + RoleChecker([Role.Enum.SUPER_ADMIN]), async (req, res) => { const { mailingList, subject, htmlBody } = req.body; @@ -146,7 +146,7 @@ subscriptionRouter.post( // API body: {String} email (the singular email to send to), {String} subject : The subject line of the email, {String} htmlBody : The HTML content of the email. subscriptionRouter.post( "/send-email/single", - RoleChecker([Role.Enum.ADMIN]), + RoleChecker([Role.Enum.SUPER_ADMIN]), async (req, res) => { const { email, subject, htmlBody } = req.body; diff --git a/testing/testingTools.ts b/testing/testingTools.ts index 069574bf..64c29ba7 100644 --- a/testing/testingTools.ts +++ b/testing/testingTools.ts @@ -55,6 +55,10 @@ export function getAsAdmin(url: string): request.Test { return get(url, Role.enum.ADMIN); } +export function getAsSuperAdmin(url: string): request.Test { + return get(url, Role.enum.SUPER_ADMIN); +} + export function getAsCorporate(url: string): request.Test { return get(url, Role.enum.CORPORATE); } @@ -86,6 +90,10 @@ export function postAsAdmin(url: string): request.Test { return post(url, Role.enum.ADMIN); } +export function postAsSuperAdmin(url: string): request.Test { + return post(url, Role.enum.SUPER_ADMIN); +} + export function postAsCorporate(url: string): request.Test { return post(url, Role.enum.CORPORATE); } @@ -106,6 +114,10 @@ export function putAsAdmin(url: string): request.Test { return put(url, Role.enum.ADMIN); } +export function putAsSuperAdmin(url: string): request.Test { + return put(url, Role.enum.SUPER_ADMIN); +} + export function putAsCorporate(url: string): request.Test { return put(url, Role.enum.CORPORATE); } @@ -126,6 +138,10 @@ export function patchAsAdmin(url: string): request.Test { return patch(url, Role.enum.ADMIN); } +export function patchAsSuperAdmin(url: string): request.Test { + return patch(url, Role.enum.SUPER_ADMIN); +} + export function patchAsCorporate(url: string): request.Test { return patch(url, Role.enum.CORPORATE); } @@ -146,6 +162,10 @@ export function delAsAdmin(url: string): request.Test { return del(url, Role.enum.ADMIN); } +export function delAsSuperAdmin(url: string): request.Test { + return del(url, Role.enum.SUPER_ADMIN); +} + export function delAsCorporate(url: string): request.Test { return del(url, Role.enum.CORPORATE); }