diff --git a/apps/api/src/lib/functions/database.ts b/apps/api/src/lib/functions/database.ts index 6e12e3f..eca9626 100644 --- a/apps/api/src/lib/functions/database.ts +++ b/apps/api/src/lib/functions/database.ts @@ -1,4 +1,4 @@ -import { userToTeam, db, and, eq, log } from "db"; +import { userToTeam, db, and, eq, log, team, teamJoinRequest } from "db"; import type { UserType, SiteRoleType } from "db/types"; import type { LoggingOptions, LoggingType } from "../types"; import { type Context } from "hono"; @@ -44,6 +44,22 @@ export function isSiteAdminUser( return ["ADMIN", "SUPER_ADMIN"].some((role) => role === permissionEnum); } +export async function findTeam(teamId: string) { + return db.query.team.findFirst({ + where: eq(team.id, teamId), + }); +} + +export async function findTeamUserFacing(teamId: string) { + return db.query.team.findFirst({ + columns: { + id: true, + name: true, + }, + where: eq(team.id, teamId), + }); +} + export async function leaveTeam(userId: string, teamId: string) { return db .delete(userToTeam) @@ -62,6 +78,33 @@ export async function getAdminUserForTeam(userId: string, teamId: string) { ), }); } + +export async function getJoinTeamRequest( + requestId: string, + userId: string, + teamId: string, +) { + return db.query.teamJoinRequest.findFirst({ + where: and( + eq(teamJoinRequest.id, requestId), + eq(teamJoinRequest.userId, userId), + eq(teamJoinRequest.teamId, teamId), + ), + }); +} + +export async function getJoinTeamRequestAdmin( + requestId: string, + teamId: string, +) { + return db.query.teamJoinRequest.findFirst({ + where: and( + eq(teamJoinRequest.id, requestId), + eq(teamJoinRequest.teamId, teamId), + ), + }); +} + // TODO: This function is lowkey pivotal so we should ensure it is WAI. export async function isUserSiteAdminOrQueryHasPermissions( userSiteRole: SiteRoleType, diff --git a/apps/api/src/routes/team.ts b/apps/api/src/routes/team.ts index 12200cc..f432b13 100644 --- a/apps/api/src/routes/team.ts +++ b/apps/api/src/routes/team.ts @@ -1,4 +1,4 @@ -// TODO(https://github.com/acmutsa/Fallback/issues/35): Come back and finish team routes +// TODO(https://github.com/acmutsa/Fallback/issues/45): Come back and make sure we only send the data the user needs import { zValidator } from "@hono/zod-validator"; import { HonoBetterAuth } from "../lib/functions"; import { @@ -9,12 +9,15 @@ import { userToTeam, teamInvite, team, + teamJoinRequest, + desc, } from "db"; import { joinTeamSchema, userTeamActionSchema, teamIdSchema, teamNameSchema, + teamRequestSchema, } from "shared/zod"; import { API_ERROR_MESSAGES } from "shared"; import { @@ -24,6 +27,10 @@ import { logError, logWarning, maybeGetDbErrorCode, + findTeamUserFacing, + getJoinTeamRequest, + getJoinTeamRequestAdmin, + findTeam, } from "../lib/functions/database"; import { isSiteAdminUser } from "../lib/functions/database"; import { isPast } from "date-fns"; @@ -53,141 +60,154 @@ const teamHandler = HonoBetterAuth() if (!user || !isSiteAdminUser(user.siteRole)) { return c.json( { - message: "Please log in.", - code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + message: + "You do not have permission to access this resource.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, }, - 401, + 403, ); } const allTeams = await db.query.team.findMany(); return c.json({ data: allTeams }, 200); }) - .post("/join", zValidator("query", joinTeamSchema), async (c) => { - const inv = c.req.valid("query").inv; - const user = c.get("user"); - - if (!user) { - return c.json( - { - message: "Please log in.", - code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, - }, - 401, - ); - } + .post( + "/invites/:inviteId/accept", + zValidator("param", joinTeamSchema), + async (c) => { + const inv = c.req.valid("param").inviteId; + const user = c.get("user"); - if (!inv) { - return c.json( - { - message: "Invite code is required to join a team.", - code: API_ERROR_MESSAGES.NO_INVITE_CODE, - }, - 400, - ); - } - const inviteRequest = await db.query.teamInvite.findFirst({ - where: and( - eq(teamInvite.id, inv), - eq(teamInvite.email, user.email), - ), - }); + if (!user) { + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); + } - if (!inviteRequest) { - return c.json( - { - message: "Invite code not found for this email.", - code: API_ERROR_MESSAGES.CODE_NOT_FOUND, - }, - 400, - ); - } + const invite = await db.query.teamInvite.findFirst({ + where: and( + eq(teamInvite.id, inv), + eq(teamInvite.email, user.email), + ), + }); - if (inviteRequest.acceptedAt) { - return c.json( - { - message: "User is already a member of this team.", - code: API_ERROR_MESSAGES.ALREADY_MEMBER, - }, - 400, - ); - } - // Check if the invite has expired - if (inviteRequest.expiresAt && isPast(inviteRequest.expiresAt)) { - return c.json( - { - message: "Invite code has expired.", - code: API_ERROR_MESSAGES.CODE_EXPIRED, - }, - 400, - ); - } + if (!invite) { + return c.json( + { + message: "Invite code not found for this email.", + code: API_ERROR_MESSAGES.CODE_NOT_FOUND, + }, + 400, + ); + } - c.set("teamId", inviteRequest.teamId); + if (invite.acceptedAt) { + return c.json( + { + message: "Invite code has already been used.", + code: API_ERROR_MESSAGES.INVITE_CODE_USED, + }, + 400, + ); + } + // Check if the invite has expired + if (invite.expiresAt && isPast(invite.expiresAt)) { + return c.json( + { + message: "Invite code has expired.", + code: API_ERROR_MESSAGES.CODE_EXPIRED, + }, + 400, + ); + } - try { - await db.transaction(async (tx) => { - await tx.insert(userToTeam).values({ - teamId: inviteRequest.teamId, - userId: user.id, - role: inviteRequest.role, + c.set("teamId", invite.teamId); + + try { + await db.transaction(async (tx) => { + await tx.insert(userToTeam).values({ + teamId: invite.teamId, + userId: user.id, + role: invite.role, + }); + + await tx + .update(teamInvite) + .set({ + acceptedAt: new Date(), + }) + .where(eq(teamInvite.id, inv)); }); - - await tx - .update(teamInvite) - .set({ - acceptedAt: new Date(), - }) - .where(eq(teamInvite.id, inv)); - }); - } catch (e) { - const errorCode = maybeGetDbErrorCode(e); - if (errorCode === "SQLITE_CONSTRAINT") { - logWarning( - `User with ID ${user.id} is already a member of team with ID ${inviteRequest.teamId}. Transaction has been rolled back.`, + } catch (e) { + const errorCode = maybeGetDbErrorCode(e); + if (errorCode === "SQLITE_CONSTRAINT") { + logWarning( + `User with ID ${user.id} is already a member of team with ID ${invite.teamId}. Transaction has been rolled back.`, + c, + ); + return c.json( + { + message: "User is already a member of this team.", + code: API_ERROR_MESSAGES.ALREADY_MEMBER, + }, + 400, + ); + } + logError( + `Error occurred while user with ID ${user.id} was attempting to join team with ID ${invite.teamId}. Transaction has been rolled back. Error details: ${e}`, c, ); return c.json( { - message: "User is already a member of this team.", - code: API_ERROR_MESSAGES.ALREADY_MEMBER, + message: + "An error occurred while attempting to join the team. Please try again later.", + code: API_ERROR_MESSAGES.GENERIC_ERROR, }, - 400, + 500, ); } - logError( - `Error occurred while user with ID ${user.id} was attempting to join team with ID ${inviteRequest.teamId}. Transaction has been rolled back. Error details: ${e}`, - c, - ); + return c.json( - { - message: - "An error occurred while attempting to join the team. Please try again later.", - code: API_ERROR_MESSAGES.GENERIC_ERROR, - }, - 500, + { data: { teamId: invite.teamId, role: invite.role } }, + 200, ); - } + }, + ) + .get("/requests", async (c) => { + const user = c.get("user"); - const teamInfo = await db.query.team.findFirst({ - where: eq(team.id, inviteRequest.teamId), - }); - if (!teamInfo) { - logError( - `Team with ID ${inviteRequest.teamId} not found after accepting invite. This should not happen and indicates a critical issue. Please investigate immediately.`, - c, - ); + if (!user) { return c.json( { - message: - "Team not found after accepting invite. Please contact support.", - code: API_ERROR_MESSAGES.NOT_FOUND, + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, }, - 500, + 401, ); } - return c.json({ data: teamInfo }, 200); + const teamJoinRequests = await db.query.teamJoinRequest.findMany({ + columns: { + userId: false, + teamId: false, + }, + where: eq(teamJoinRequest.userId, user.id), + with: { + team: { + columns: { + updatedAt: false, + createdAt: false, + }, + }, + }, + orderBy: desc(teamJoinRequest.createdAt), + }); + + return c.json({ data: teamJoinRequests }, 200); }) .get("/:teamId", zValidator("param", teamIdSchema), async (c) => { const teamId = c.req.valid("param").teamId; @@ -290,6 +310,443 @@ const teamHandler = HonoBetterAuth() return c.json({ data: deletedTeamData[0] }, 200); }) + .get("/:teamId/requests", zValidator("param", teamIdSchema), async (c) => { + const teamId = c.req.valid("param").teamId; + const user = c.get("user"); + + if (!user) { + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); + } + + const canUserView = await isUserSiteAdminOrQueryHasPermissions( + user.siteRole, + getAdminUserForTeam(user.id, teamId), + ); + + if (!canUserView) { + return c.json( + { + message: + "You cannot view this team's join requests. Please contact your administrator if you believe this is an error.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, + 403, + ); + } + + const joinRequests = await db.query.teamJoinRequest.findMany({ + where: eq(teamJoinRequest.teamId, teamId), + with: { + user: { + columns: { + firstName: true, + lastName: true, + id: true, + image: true, + }, + }, + }, + orderBy: desc(teamJoinRequest.createdAt), + }); + + return c.json({ data: joinRequests }, 200); + }) + .post("/:teamId/requests", zValidator("param", teamIdSchema), async (c) => { + const teamId = c.req.valid("param").teamId; + const user = c.get("user"); + + if (!user) { + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); + } + + const doesTeamExist = await findTeam(teamId); + + if (!doesTeamExist) { + return c.json( + { + message: "Team not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); + } + + const existingRequest = await db.query.teamJoinRequest.findFirst({ + where: and( + eq(teamJoinRequest.teamId, teamId), + eq(teamJoinRequest.userId, user.id), + eq(teamJoinRequest.status, "PENDING"), + ), + }); + + if (existingRequest) { + return c.json( + { + message: + "You already have a pending join request for this team.", + code: API_ERROR_MESSAGES.JOIN_REQUEST_EXISTS, + }, + 400, + ); + } + + const isUserAlreadyMember = await db.query.userToTeam.findFirst({ + where: and( + eq(userToTeam.teamId, teamId), + eq(userToTeam.userId, user.id), + ), + }); + + if (isUserAlreadyMember) { + return c.json( + { + message: "You are already a member of this team.", + code: API_ERROR_MESSAGES.ALREADY_MEMBER, + }, + 400, + ); + } + + const newJoinRequest = await db + .insert(teamJoinRequest) + .values({ + teamId, + userId: user.id, + }) + .returning(); + + return c.json({ data: { requestId: newJoinRequest[0].id } }, 200); + }) + .post( + "/:teamId/requests/:requestId/approve", + zValidator("param", teamRequestSchema), + async (c) => { + const { teamId, requestId } = c.req.valid("param"); + const user = c.get("user"); + + if (!user) { + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); + } + + const canUserApprove = await isUserSiteAdminOrQueryHasPermissions( + user.siteRole, + getAdminUserForTeam(user.id, teamId), + ); + + if (!canUserApprove) { + return c.json( + { + message: + "You cannot approve join requests for this team. Please contact your administrator if you believe this is an error.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, + 403, + ); + } + + const joinRequest = await getJoinTeamRequestAdmin( + requestId, + teamId, + ); + + if (!joinRequest) { + return c.json( + { + message: "Join request not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); + } + + if (joinRequest.status === "APPROVED") { + return c.json( + { + message: "Join request has already been approved.", + code: API_ERROR_MESSAGES.ALREADY_APPROVED, + }, + 400, + ); + } else if (joinRequest.status === "REJECTED") { + return c.json( + { + message: "Join request has already been rejected.", + code: API_ERROR_MESSAGES.REJECTED, + }, + 400, + ); + } else if (joinRequest.status === "RESCINDED") { + return c.json( + { + message: "Join request has been rescinded by the user.", + code: API_ERROR_MESSAGES.RESCINDED, + }, + 400, + ); + } + + try { + await db.transaction(async (tx) => { + await tx.insert(userToTeam).values({ + teamId, + userId: joinRequest.userId, + role: "MEMBER", + }); + + await tx + .update(teamJoinRequest) + .set({ + status: "APPROVED", + }) + .where(eq(teamJoinRequest.id, requestId)); + }); + } catch (e) { + const errorCode = maybeGetDbErrorCode(e); + if (errorCode === "SQLITE_CONSTRAINT") { + logWarning( + `User with ID ${joinRequest.userId} is already a member of team with ID ${joinRequest.teamId}. Transaction has been rolled back.`, + c, + ); + return c.json( + { + message: "User is already a member of this team.", + code: API_ERROR_MESSAGES.ALREADY_MEMBER, + }, + 400, + ); + } + logError( + `Error occurred while user with ID ${joinRequest.userId} was attempting to accept join request for team with ID ${joinRequest.teamId}. Transaction has been rolled back. Error details: ${e}`, + c, + ); + return c.json( + { + message: + "An error occurred while attempting to accept the join request for the team. Please try again later.", + code: API_ERROR_MESSAGES.GENERIC_ERROR, + }, + 500, + ); + } + + const teamInfo = await findTeamUserFacing(joinRequest.teamId); + if (!teamInfo) { + logError( + `Team with ID ${joinRequest.teamId} not found after accepting join request. This should not happen and indicates a critical issue. Please investigate immediately.`, + c, + ); + return c.json( + { + message: + "Team not found after accepting join request. Please contact support.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 500, + ); + } + + return c.json({ data: teamInfo }, 200); + }, + ) + .post( + "/:teamId/requests/:requestId/reject", + zValidator("param", teamRequestSchema), + async (c) => { + const { teamId, requestId } = c.req.valid("param"); + const user = c.get("user"); + + if (!user) { + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); + } + + const canUserReject = await isUserSiteAdminOrQueryHasPermissions( + user.siteRole, + getAdminUserForTeam(user.id, teamId), + ); + + if (!canUserReject) { + return c.json( + { + message: + "You cannot reject join requests for this team. Please contact your administrator if you believe this is an error.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, + 403, + ); + } + + const joinRequest = await getJoinTeamRequestAdmin( + requestId, + teamId, + ); + + if (!joinRequest) { + return c.json( + { + message: "Join request not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); + } + + if (joinRequest.status === "APPROVED") { + return c.json( + { + message: "Request has already been approved.", + code: API_ERROR_MESSAGES.ALREADY_MEMBER, + }, + 400, + ); + } else if (joinRequest.status === "REJECTED") { + return c.json( + { + message: "Request has already been rejected.", + code: API_ERROR_MESSAGES.REJECTED, + }, + 400, + ); + } else if (joinRequest.status === "RESCINDED") { + return c.json( + { + message: "Request has been rescinded by the user.", + code: API_ERROR_MESSAGES.REJECTED, + }, + 400, + ); + } + + const rejectedRequest = await db + .update(teamJoinRequest) + .set({ + status: "REJECTED", + }) + .where(eq(teamJoinRequest.id, requestId)) + .returning(); + + if (rejectedRequest.length === 0) { + return c.json( + { + message: "Join request not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); + } + + return c.json( + { data: { joinRequestId: rejectedRequest[0].id } }, + 200, + ); + }, + ) + .post( + "/:teamId/requests/:requestId/rescind", + zValidator("param", teamRequestSchema), + async (c) => { + const { teamId, requestId } = c.req.valid("param"); + const user = c.get("user"); + + if (!user) { + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); + } + + const joinRequest = await getJoinTeamRequest( + requestId, + user.id, + teamId, + ); + + if (!joinRequest) { + return c.json( + { + message: "Join request not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); + } + + if (joinRequest.status === "APPROVED") { + return c.json( + { + message: + "Join request has already been approved and cannot be rescinded.", + code: API_ERROR_MESSAGES.ALREADY_MEMBER, + }, + 400, + ); + } else if (joinRequest.status === "REJECTED") { + return c.json( + { + message: + "Join request has already been rejected and cannot be rescinded.", + code: API_ERROR_MESSAGES.REJECTED, + }, + 400, + ); + } else if (joinRequest.status === "RESCINDED") { + return c.json( + { + message: "Join request has already been rescinded.", + code: API_ERROR_MESSAGES.REJECTED, + }, + 400, + ); + } + + const rescindedRequest = await db + .update(teamJoinRequest) + .set({ + status: "RESCINDED", + }) + .where(eq(teamJoinRequest.id, requestId)) + .returning(); + + if (rescindedRequest.length === 0) { + return c.json( + { + message: "Join request not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); + } + + return c.json({ data: rescindedRequest[0].id }, 200); + }, + ) + // This route should return all of the information related to a team that an admin would need. We can break it down into multiple routes if it becomes too much but for now we will just return it all. .get("/:teamId/admin", zValidator("param", teamIdSchema), async (c) => { const teamId = c.req.valid("param").teamId; const user = c.get("user"); diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 6ac119a..6fb0c68 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -44,9 +44,11 @@ export const joinTeamMutationclient = (inviteCode: string) => mutationOptions({ mutationKey: ["team", inviteCode, "join"], mutationFn: async () => { - const response = await apiClient.team.join.$post({ - query: { - inv: inviteCode, + const response = await apiClient.team.invites[ + ":inviteId" + ].accept.$post({ + param: { + inviteId: inviteCode, }, }); if (response?.status === 200) { diff --git a/apps/web/src/routes/team/join.tsx b/apps/web/src/routes/team/join.tsx index 6416095..568fb9d 100644 --- a/apps/web/src/routes/team/join.tsx +++ b/apps/web/src/routes/team/join.tsx @@ -1,17 +1,22 @@ import { createFileRoute } from "@tanstack/react-router"; -import { joinTeamSchema } from "shared/zod"; +import { joinTeamSchema as joinTeamSchemaZod } from "shared/zod"; /* * Team Join Route * This route will allow users to join a team via an invite code or through the team ID. Team ID is only allowed if the team does not require an invite code. */ + +const joinTeamSchemaInviteOption = joinTeamSchemaZod + .pick({ inviteId: true }) + .partial(); export const Route = createFileRoute("/team/join")({ component: RouteComponent, - validateSearch: (searchParams) => joinTeamSchema.parse(searchParams), + validateSearch: (searchParams) => + joinTeamSchemaInviteOption.parse(searchParams), }); function RouteComponent() { - const { inv } = Route.useSearch(); + const { inviteId } = Route.useSearch(); - return
Hello "/team/join"! Invite code is {inv}
; + return
Hello "/team/join"! Invite code is {inviteId}
; } diff --git a/packages/db/drizzle/0011_robust_sabretooth.sql b/packages/db/drizzle/0011_robust_sabretooth.sql new file mode 100644 index 0000000..f6ca5ad --- /dev/null +++ b/packages/db/drizzle/0011_robust_sabretooth.sql @@ -0,0 +1 @@ +ALTER TABLE `user_to_team` ADD `joined_on` integer DEFAULT (current_timestamp) NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0011_snapshot.json b/packages/db/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..1f1913c --- /dev/null +++ b/packages/db/drizzle/meta/0011_snapshot.json @@ -0,0 +1,835 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "39b0cf06-8d6d-489a-875e-cc3b19d55c00", + "prevId": "d7c9dc2a-5434-4d41-bd17-a6a3f1137369", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_job": { + "name": "backup_job", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authentication_data": { + "name": "authentication_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "database_type": { + "name": "database_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_string": { + "name": "cron_string", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "backup_job_team_id_team_id_fk": { + "name": "backup_job_team_id_team_id_fk", + "tableFrom": "backup_job", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_job_run": { + "name": "backup_job_run", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "invocation_type": { + "name": "invocation_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backup_job_id": { + "name": "backup_job_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "backup_job_run_backup_job_id_backup_job_id_fk": { + "name": "backup_job_run_backup_job_id_backup_job_id_fk", + "tableFrom": "backup_job_run", + "tableTo": "backup_job", + "columnsFrom": [ + "backup_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "log": { + "name": "log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "log_type": { + "name": "log_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "team_id": { + "name": "team_id", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "route": { + "name": "route", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_elapsed_ms": { + "name": "time_elapsed_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team": { + "name": "team", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_invite": { + "name": "team_invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'MEMBER'" + } + }, + "indexes": {}, + "foreignKeys": { + "team_invite_team_id_team_id_fk": { + "name": "team_invite_team_id_team_id_fk", + "tableFrom": "team_invite", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_join_request": { + "name": "team_join_request", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'PENDING'" + } + }, + "indexes": {}, + "foreignKeys": { + "team_join_request_team_id_team_id_fk": { + "name": "team_join_request_team_id_team_id_fk", + "tableFrom": "team_join_request", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_join_request_user_id_user_id_fk": { + "name": "team_join_request_user_id_user_id_fk", + "tableFrom": "team_join_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_name": { + "name": "last_name", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen": { + "name": "last_seen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "site_role": { + "name": "site_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USER'" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_to_team": { + "name": "user_to_team", + "columns": { + "user_id": { + "name": "user_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'MEMBER'" + }, + "joined_on": { + "name": "joined_on", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_to_team_user_id_user_id_fk": { + "name": "user_to_team_user_id_user_id_fk", + "tableFrom": "user_to_team", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_to_team_team_id_team_id_fk": { + "name": "user_to_team_team_id_team_id_fk", + "tableFrom": "user_to_team", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_to_team_user_id_team_id_pk": { + "columns": [ + "user_id", + "team_id" + ], + "name": "user_to_team_user_id_team_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 807786b..3395974 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1771097455887, "tag": "0010_salty_romulus", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1771399307185, + "tag": "0011_robust_sabretooth", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index b091846..192479d 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -41,7 +41,7 @@ const backupResult = text({ enum: ["SUCCESS", "FAILURE", "CANCELED"] }); const memberRoleType = text({ enum: ["ADMIN", "MEMBER"] }); const siteRoleType = text({ enum: ["SUPER_ADMIN", "ADMIN", "USER"] }); const teamJoinRequestStatusType = text({ - enum: ["PENDING", "APPROVED", "REJECTED"], + enum: ["PENDING", "APPROVED", "REJECTED", "RESCINDED"], }); // User Table - Partially generated based on Better Auth requirements. Modify with extreme caution. @@ -74,6 +74,7 @@ export const team = sqliteTable("team", { export const teamRelations = relations(team, ({ many }) => ({ members: many(userToTeam), invites: many(teamInvite), + joinRequests: many(teamJoinRequest), logs: many(log), backupJobs: many(backupJob), })); @@ -88,6 +89,7 @@ export const userToTeam = sqliteTable( onDelete: "cascade", }), role: memberRoleType.notNull().default("MEMBER"), + joinedOn: standardDateFactory(), }, (table) => [ primaryKey({ columns: [table.userId, table.teamId] }), // composite primary key @@ -136,7 +138,19 @@ export const teamJoinRequest = sqliteTable("team_join_request", { status: teamJoinRequestStatusType.notNull().default("PENDING"), }); -// TODO(https://github.com/acmutsa/Fallback/issues/35): Come back and add team join requests +export const teamJoinRequestRelations = relations( + teamJoinRequest, + ({ one }) => ({ + team: one(team, { + fields: [teamJoinRequest.teamId], + references: [team.id], + }), + user: one(user, { + fields: [teamJoinRequest.userId], + references: [user.id], + }), + }), +); export const backupJob = sqliteTable("backup_job", { id: standardIdFactory("job_").primaryKey(), diff --git a/packages/shared/constants.ts b/packages/shared/constants.ts index 758ee71..4d065f3 100644 --- a/packages/shared/constants.ts +++ b/packages/shared/constants.ts @@ -66,6 +66,7 @@ export const THEME_CONFIG = { export const API_ERROR_MESSAGES = { NO_INVITE_CODE: "NO_INVITE_CODE", + INVITE_CODE_USED: "INVITE_CODE_USED", CODE_NOT_FOUND: "CODE_NOT_FOUND", CODE_EXPIRED: "CODE_EXPIRED", NOT_FOUND: "NOT_FOUND", @@ -73,4 +74,8 @@ export const API_ERROR_MESSAGES = { ALREADY_MEMBER: "ALREADY_MEMBER", GENERIC_ERROR: "GENERIC_ERROR", NOT_AUTHENTICATED: "NOT_AUTHENTICATED", + REJECTED: "REJECTED", + RESCINDED: "RESCINDED", + ALREADY_APPROVED: "ALREADY_APPROVED", + JOIN_REQUEST_EXISTS: "JOIN_REQUEST_EXISTS", }; diff --git a/packages/shared/zod.ts b/packages/shared/zod.ts index 7b03a6b..88dd558 100644 --- a/packages/shared/zod.ts +++ b/packages/shared/zod.ts @@ -1,7 +1,9 @@ import z from "zod"; +const teamId = z.string().min(1).max(30); + export const teamIdSchema = z.object({ - teamId: z.string().min(1).max(30), + teamId, }); export const userTeamActionSchema = z.object({ @@ -10,9 +12,14 @@ export const userTeamActionSchema = z.object({ }); export const joinTeamSchema = z.object({ - inv: z.string().min(1).max(30).optional(), + inviteId: z.string().min(1).max(30), }); export const teamNameSchema = z.object({ name: z.string().min(1).max(255), }); + +export const teamRequestSchema = z.object({ + teamId, + requestId: z.string().min(1).max(50), +});