chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
import { UpdateVacationStatusSchema, getPublicHolidays } from "@planarchy/shared";
|
||||
import { VacationStatus, VacationType } from "@planarchy/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||
import { sendEmail } from "../lib/email.js";
|
||||
|
||||
/** Types that consume from annual leave balance */
|
||||
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
|
||||
|
||||
/** Send in-app notification + optional email when vacation status changes */
|
||||
async function notifyVacationStatus(
|
||||
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
|
||||
vacationId: string,
|
||||
resourceId: string,
|
||||
newStatus: VacationStatus,
|
||||
rejectionReason?: string | null,
|
||||
) {
|
||||
// Find the resource's linked user
|
||||
const resource = await db.resource.findUnique({
|
||||
where: { id: resourceId },
|
||||
select: {
|
||||
displayName: true,
|
||||
user: { select: { id: true, email: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!resource?.user) return;
|
||||
|
||||
const statusLabel = newStatus === VacationStatus.APPROVED ? "approved" : "rejected";
|
||||
const title = `Vacation request ${statusLabel}`;
|
||||
const body = rejectionReason
|
||||
? `Your vacation request was ${statusLabel}. Reason: ${rejectionReason}`
|
||||
: `Your vacation request has been ${statusLabel}.`;
|
||||
|
||||
// In-app notification
|
||||
const notification = await db.notification.create({
|
||||
data: {
|
||||
userId: resource.user.id,
|
||||
type: `VACATION_${newStatus}`,
|
||||
title,
|
||||
body,
|
||||
entityId: vacationId,
|
||||
entityType: "vacation",
|
||||
},
|
||||
});
|
||||
emitNotificationCreated(resource.user.id, notification.id);
|
||||
|
||||
// Email (non-blocking)
|
||||
if (resource.user.email) {
|
||||
void sendEmail({
|
||||
to: resource.user.email,
|
||||
subject: `Planarchy — ${title}`,
|
||||
text: body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const vacationRouter = createTRPCRouter({
|
||||
/**
|
||||
* List vacations with optional filters.
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string().optional(),
|
||||
status: z.nativeEnum(VacationStatus).optional(),
|
||||
type: z.nativeEnum(VacationType).optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
limit: z.number().min(1).max(500).default(100),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db.vacation.findMany({
|
||||
where: {
|
||||
...(input.resourceId ? { resourceId: input.resourceId } : {}),
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.type ? { type: input.type } : {}),
|
||||
...(input.startDate ? { endDate: { gte: input.startDate } } : {}),
|
||||
...(input.endDate ? { startDate: { lte: input.endDate } } : {}),
|
||||
},
|
||||
include: {
|
||||
resource: { select: { id: true, displayName: true, eid: true } },
|
||||
requestedBy: { select: { id: true, name: true, email: true } },
|
||||
approvedBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
take: input.limit,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single vacation by ID.
|
||||
*/
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const vacation = await ctx.db.vacation.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
resource: { select: { id: true, displayName: true, eid: true } },
|
||||
requestedBy: { select: { id: true, name: true, email: true } },
|
||||
approvedBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
if (!vacation) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
|
||||
}
|
||||
return vacation;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a vacation request.
|
||||
* - MANAGER/ADMIN → auto-approved
|
||||
* - USER → PENDING
|
||||
* Adds isHalfDay + halfDayPart support.
|
||||
*/
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
type: z.nativeEnum(VacationType),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
note: z.string().max(500).optional(),
|
||||
isHalfDay: z.boolean().optional(),
|
||||
halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(),
|
||||
}).refine((d) => d.endDate >= d.startDate, {
|
||||
message: "End date must be after start date",
|
||||
path: ["endDate"],
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userRecord = await ctx.db.user.findUnique({
|
||||
where: { email: ctx.session.user?.email ?? "" },
|
||||
select: { id: true, systemRole: true },
|
||||
});
|
||||
if (!userRecord) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
// Check for overlapping APPROVED or PENDING vacations
|
||||
const overlapping = await ctx.db.vacation.findFirst({
|
||||
where: {
|
||||
resourceId: input.resourceId,
|
||||
status: { in: ["APPROVED", "PENDING"] },
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
},
|
||||
});
|
||||
if (overlapping) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Overlapping vacation already exists for this resource in the selected period",
|
||||
});
|
||||
}
|
||||
|
||||
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
|
||||
const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING;
|
||||
|
||||
const vacation = await ctx.db.vacation.create({
|
||||
data: {
|
||||
resourceId: input.resourceId,
|
||||
type: input.type,
|
||||
status,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
...(input.note !== undefined ? { note: input.note } : {}),
|
||||
isHalfDay: input.isHalfDay ?? false,
|
||||
...(input.halfDayPart !== undefined ? { halfDayPart: input.halfDayPart } : {}),
|
||||
requestedById: userRecord.id,
|
||||
...(isManager
|
||||
? { approvedById: userRecord.id, approvedAt: new Date() }
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
resource: { select: { id: true, displayName: true, eid: true } },
|
||||
requestedBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status });
|
||||
|
||||
return vacation;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Approve a vacation (manager/admin only).
|
||||
*/
|
||||
approve: managerProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
|
||||
}
|
||||
const approvableStatuses: string[] = [VacationStatus.PENDING, VacationStatus.CANCELLED, VacationStatus.REJECTED];
|
||||
if (!approvableStatuses.includes(existing.status)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved" });
|
||||
}
|
||||
|
||||
const userRecord = await ctx.db.user.findUnique({
|
||||
where: { email: ctx.session.user?.email ?? "" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const updated = await ctx.db.vacation.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: VacationStatus.APPROVED,
|
||||
rejectionReason: null,
|
||||
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||
|
||||
if (existing.status === VacationStatus.PENDING) {
|
||||
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reject a vacation (manager/admin only).
|
||||
*/
|
||||
reject: managerProcedure
|
||||
.input(z.object({ id: z.string(), rejectionReason: z.string().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
|
||||
}
|
||||
if (existing.status !== VacationStatus.PENDING) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING vacations can be rejected" });
|
||||
}
|
||||
|
||||
const updated = await ctx.db.vacation.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: VacationStatus.REJECTED,
|
||||
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason);
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Batch approve multiple pending vacations (manager/admin only).
|
||||
*/
|
||||
batchApprove: managerProcedure
|
||||
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userRecord = await ctx.db.user.findUnique({
|
||||
where: { email: ctx.session.user?.email ?? "" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: { id: { in: input.ids }, status: VacationStatus.PENDING },
|
||||
select: { id: true, resourceId: true },
|
||||
});
|
||||
|
||||
await ctx.db.vacation.updateMany({
|
||||
where: { id: { in: vacations.map((v) => v.id) } },
|
||||
data: {
|
||||
status: VacationStatus.APPROVED,
|
||||
rejectionReason: null,
|
||||
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
for (const v of vacations) {
|
||||
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
|
||||
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
|
||||
}
|
||||
|
||||
return { approved: vacations.length };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Batch reject multiple pending vacations (manager/admin only).
|
||||
*/
|
||||
batchReject: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()).min(1).max(100),
|
||||
rejectionReason: z.string().max(500).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: { id: { in: input.ids }, status: VacationStatus.PENDING },
|
||||
select: { id: true, resourceId: true },
|
||||
});
|
||||
|
||||
await ctx.db.vacation.updateMany({
|
||||
where: { id: { in: vacations.map((v) => v.id) } },
|
||||
data: {
|
||||
status: VacationStatus.REJECTED,
|
||||
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
for (const v of vacations) {
|
||||
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
|
||||
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason);
|
||||
}
|
||||
|
||||
return { rejected: vacations.length };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Cancel a vacation (owner or manager).
|
||||
*/
|
||||
cancel: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
|
||||
}
|
||||
if (existing.status === VacationStatus.CANCELLED) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" });
|
||||
}
|
||||
|
||||
const updated = await ctx.db.vacation.update({
|
||||
where: { id: input.id },
|
||||
data: { status: VacationStatus.CANCELLED },
|
||||
});
|
||||
|
||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||
return updated;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all APPROVED vacations for a resource in a date range (used by calculator).
|
||||
*/
|
||||
getForResource: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: input.resourceId,
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
status: true,
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all PENDING vacations awaiting approval (manager/admin only).
|
||||
*/
|
||||
getPendingApprovals: managerProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.vacation.findMany({
|
||||
where: { status: VacationStatus.PENDING },
|
||||
include: {
|
||||
resource: { select: { id: true, displayName: true, eid: true } },
|
||||
requestedBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get team overlap: other vacations in the same chapter for a given period.
|
||||
* Used by the creation modal to warn the requester.
|
||||
*/
|
||||
getTeamOverlap: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Find the chapter of the requesting resource
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: { chapter: true },
|
||||
});
|
||||
if (!resource?.chapter) return [];
|
||||
|
||||
// Find team members in the same chapter who are off in this period
|
||||
return ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resource: { chapter: resource.chapter },
|
||||
resourceId: { not: input.resourceId },
|
||||
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
},
|
||||
include: {
|
||||
resource: { select: { id: true, displayName: true, eid: true } },
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
take: 20,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Batch-create public holidays for all resources (or a chapter) for a given year+state.
|
||||
* Admin-only. Creates as APPROVED automatically.
|
||||
*/
|
||||
batchCreatePublicHolidays: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
year: z.number().int().min(2000).max(2100),
|
||||
federalState: z.string().optional(), // e.g. "BY"
|
||||
chapter: z.string().optional(), // filter to a chapter
|
||||
replaceExisting: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const holidays = getPublicHolidays(input.year, input.federalState);
|
||||
if (holidays.length === 0) {
|
||||
return { created: 0 };
|
||||
}
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const adminUser = await ctx.db.user.findUnique({
|
||||
where: { email: ctx.session.user?.email ?? "" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!adminUser) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
let created = 0;
|
||||
|
||||
for (const resource of resources) {
|
||||
for (const holiday of holidays) {
|
||||
const startDate = new Date(holiday.date);
|
||||
const endDate = new Date(holiday.date);
|
||||
|
||||
if (input.replaceExisting) {
|
||||
// Remove any existing public holiday on this exact date for this resource
|
||||
await ctx.db.vacation.deleteMany({
|
||||
where: {
|
||||
resourceId: resource.id,
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check if one already exists
|
||||
const exists = await ctx.db.vacation.findFirst({
|
||||
where: {
|
||||
resourceId: resource.id,
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
});
|
||||
if (exists) continue;
|
||||
|
||||
await ctx.db.vacation.create({
|
||||
data: {
|
||||
resourceId: resource.id,
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate,
|
||||
endDate,
|
||||
note: holiday.name,
|
||||
requestedById: adminUser.id,
|
||||
approvedById: adminUser.id,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
return { created, holidays: holidays.length, resources: resources.length };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update vacation status (approve/reject/cancel via schema).
|
||||
*/
|
||||
updateStatus: protectedProcedure
|
||||
.input(UpdateVacationStatusSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
|
||||
}
|
||||
|
||||
const userRecord = await ctx.db.user.findUnique({
|
||||
where: { email: ctx.session.user?.email ?? "" },
|
||||
select: { id: true, systemRole: true },
|
||||
});
|
||||
if (!userRecord) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
|
||||
|
||||
if (input.status !== "CANCELLED" && !isManager) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Manager role required to approve/reject" });
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = { status: input.status };
|
||||
if (input.status === "APPROVED") {
|
||||
data.approvedById = userRecord.id;
|
||||
data.approvedAt = new Date();
|
||||
data.rejectionReason = null;
|
||||
}
|
||||
if (input.note !== undefined) {
|
||||
data.note = input.note;
|
||||
}
|
||||
|
||||
const updated = await ctx.db.vacation.update({
|
||||
where: { id: input.id },
|
||||
data,
|
||||
});
|
||||
|
||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user