280 lines
14 KiB
TypeScript
280 lines
14 KiB
TypeScript
// P17 HR-Hub workflows (AK-P17b) — announcements domain service.
|
|
//
|
|
// Company announcements with audience targeting, optional acknowledgement
|
|
// (read-receipt), reactions and single-level comments. NO React/UI imports.
|
|
// Every tenant access runs inside withTenantRLS(); content writes are audited
|
|
// (M001). Announcements are not CRM subjects, so no Activity-Stream entry
|
|
// (DoD 14 N/A); a required acknowledgement notifies the audience via M011 at the
|
|
// route layer when first published.
|
|
|
|
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
|
|
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
|
|
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
|
|
import { isHrAudienceVisible, requireValidHrAudienceWithTx } from "./hrAudience.server";
|
|
import { HrServiceError } from "./hrService.server";
|
|
|
|
const HR_AUDIT_MODULE = "hr";
|
|
const MAX_TITLE = 255;
|
|
const MAX_BODY = 20000;
|
|
const MAX_COMMENT = 4000;
|
|
|
|
export type HrAudienceKind = "all" | "team" | "location";
|
|
|
|
export type AnnouncementRow = {
|
|
id: string;
|
|
tenantId: string;
|
|
authorUserId: string;
|
|
title: string;
|
|
body: string;
|
|
audienceKind: HrAudienceKind;
|
|
audienceRef: string | null;
|
|
requiresAcknowledgement: boolean;
|
|
pinnedAt: Date | null;
|
|
publishAt: Date | null;
|
|
expiresAt: Date | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
archivedAt: Date | null;
|
|
};
|
|
|
|
async function audit(input: { tenantId: string; actorId: string; action: string; targetId: string }): Promise<void> {
|
|
await createAuditEvent({
|
|
tenantId: input.tenantId,
|
|
actorId: input.actorId,
|
|
actorType: "user",
|
|
action: input.action,
|
|
targetType: "HrAnnouncement",
|
|
targetId: input.targetId,
|
|
sourceModule: HR_AUDIT_MODULE,
|
|
outcome: "success",
|
|
});
|
|
}
|
|
|
|
/** True when an announcement targeting `kind`/`ref` is visible to a viewer in
|
|
* `team`/`locationId`. Pure helper, reused by feed filtering. */
|
|
export function isAnnouncementVisible(kind: HrAudienceKind, ref: string | null, viewer: { team: string | null; locationId: string | null }): boolean {
|
|
return isHrAudienceVisible(kind, ref, viewer);
|
|
}
|
|
|
|
export type CreateAnnouncementInput = {
|
|
tenantId: string;
|
|
authorUserId: string;
|
|
title: string;
|
|
body: string;
|
|
audienceKind?: HrAudienceKind;
|
|
audienceRef?: string | null;
|
|
requiresAcknowledgement?: boolean;
|
|
pinned?: boolean;
|
|
publishAt?: Date | null;
|
|
expiresAt?: Date | null;
|
|
isDemoData?: boolean;
|
|
};
|
|
|
|
export async function createAnnouncement(input: CreateAnnouncementInput): Promise<AnnouncementRow> {
|
|
const title = input.title.trim();
|
|
const body = input.body.trim();
|
|
if (!title) throw new HrServiceError("hr.error.announcementTitleRequired");
|
|
if (!body) throw new HrServiceError("hr.error.announcementBodyRequired");
|
|
const audienceKind = input.audienceKind ?? "all";
|
|
const audienceRef = audienceKind === "all" ? null : (input.audienceRef ?? null);
|
|
const created = (await withTenantRLS(input.tenantId, async (tx) => {
|
|
await requireValidHrAudienceWithTx(tx, { tenantId: input.tenantId, kind: audienceKind, ref: audienceRef });
|
|
return tx.hrAnnouncement.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
authorUserId: input.authorUserId,
|
|
title: title.slice(0, MAX_TITLE),
|
|
body: body.slice(0, MAX_BODY),
|
|
audienceKind,
|
|
audienceRef,
|
|
requiresAcknowledgement: input.requiresAcknowledgement ?? false,
|
|
pinnedAt: input.pinned ? new Date() : null,
|
|
publishAt: input.publishAt ?? null,
|
|
expiresAt: input.expiresAt ?? null,
|
|
isDemoData: input.isDemoData ?? false,
|
|
},
|
|
});
|
|
})) as AnnouncementRow;
|
|
await audit({ tenantId: input.tenantId, actorId: input.authorUserId, action: "HR_ANNOUNCEMENT_CREATE", targetId: created.id });
|
|
return created;
|
|
}
|
|
|
|
export type UpdateAnnouncementInput = Partial<Omit<CreateAnnouncementInput, "tenantId" | "authorUserId">> & {
|
|
tenantId: string;
|
|
actorUserId: string;
|
|
announcementId: string;
|
|
};
|
|
|
|
export async function updateAnnouncement(input: UpdateAnnouncementInput): Promise<void> {
|
|
if (input.title !== undefined && !input.title.trim()) throw new HrServiceError("hr.error.announcementTitleRequired");
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
const existing = await tx.hrAnnouncement.findFirst({
|
|
where: { id: input.announcementId, tenantId: input.tenantId },
|
|
select: { id: true, audienceKind: true, audienceRef: true },
|
|
});
|
|
if (!existing) throw new CatalogError(foundationErrors.notFound, { announcementId: input.announcementId });
|
|
const audienceKind = input.audienceKind ?? existing.audienceKind;
|
|
const audienceRef = audienceKind === "all" ? null : input.audienceRef !== undefined ? input.audienceRef : existing.audienceRef;
|
|
await requireValidHrAudienceWithTx(tx, { tenantId: input.tenantId, kind: audienceKind, ref: audienceRef });
|
|
await tx.hrAnnouncement.update({
|
|
where: { id: input.announcementId },
|
|
data: {
|
|
...(input.title !== undefined ? { title: input.title.trim().slice(0, MAX_TITLE) } : {}),
|
|
...(input.body !== undefined ? { body: input.body.trim().slice(0, MAX_BODY) } : {}),
|
|
audienceKind,
|
|
audienceRef,
|
|
...(input.requiresAcknowledgement !== undefined ? { requiresAcknowledgement: input.requiresAcknowledgement } : {}),
|
|
...(input.pinned !== undefined ? { pinnedAt: input.pinned ? new Date() : null } : {}),
|
|
...(input.publishAt !== undefined ? { publishAt: input.publishAt } : {}),
|
|
...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}),
|
|
},
|
|
});
|
|
});
|
|
await audit({ tenantId: input.tenantId, actorId: input.actorUserId, action: "HR_ANNOUNCEMENT_UPDATE", targetId: input.announcementId });
|
|
}
|
|
|
|
export async function archiveAnnouncement(input: { tenantId: string; actorUserId: string; announcementId: string }): Promise<void> {
|
|
await withTenantRLS(input.tenantId, (tx) => tx.hrAnnouncement.updateMany({ where: { id: input.announcementId, tenantId: input.tenantId }, data: { archivedAt: new Date() } }));
|
|
await audit({ tenantId: input.tenantId, actorId: input.actorUserId, action: "HR_ANNOUNCEMENT_ARCHIVE", targetId: input.announcementId });
|
|
}
|
|
|
|
export type FeedEntry = AnnouncementRow & { isRead: boolean; isAcknowledged: boolean; reactionCount: number; commentCount: number };
|
|
|
|
/** Published, non-archived, non-expired announcements visible to the viewer,
|
|
* pinned first then newest, annotated with the viewer's read/ack state. */
|
|
export async function listFeed(input: { tenantId: string; userId: string; viewerTeam: string | null; viewerLocationId: string | null; now?: Date }): Promise<FeedEntry[]> {
|
|
const now = input.now ?? new Date();
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
const rows = (await tx.hrAnnouncement.findMany({
|
|
where: {
|
|
tenantId: input.tenantId,
|
|
archivedAt: null,
|
|
OR: [{ publishAt: null }, { publishAt: { lte: now } }],
|
|
AND: [{ OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] }],
|
|
},
|
|
orderBy: [{ pinnedAt: "desc" }, { createdAt: "desc" }],
|
|
})) as AnnouncementRow[];
|
|
const visible = rows.filter((row) => isAnnouncementVisible(row.audienceKind, row.audienceRef, { team: input.viewerTeam, locationId: input.viewerLocationId }));
|
|
const ids = visible.map((row) => row.id);
|
|
const [reads, reactionCounts, commentCounts] = await Promise.all([
|
|
tx.hrAnnouncementRead.findMany({ where: { tenantId: input.tenantId, userId: input.userId, announcementId: { in: ids } } }),
|
|
tx.hrAnnouncementReaction.groupBy({ by: ["announcementId"], where: { tenantId: input.tenantId, announcementId: { in: ids } }, _count: { _all: true } }),
|
|
tx.hrAnnouncementComment.groupBy({ by: ["announcementId"], where: { tenantId: input.tenantId, announcementId: { in: ids }, archivedAt: null }, _count: { _all: true } }),
|
|
]);
|
|
const readByAnn = new Map(reads.map((r) => [r.announcementId, r]));
|
|
const reactionByAnn = new Map(reactionCounts.map((r) => [r.announcementId, r._count._all]));
|
|
const commentByAnn = new Map(commentCounts.map((c) => [c.announcementId, c._count._all]));
|
|
return visible.map((row) => ({
|
|
...row,
|
|
isRead: readByAnn.has(row.id),
|
|
isAcknowledged: Boolean(readByAnn.get(row.id)?.acknowledgedAt),
|
|
reactionCount: reactionByAnn.get(row.id) ?? 0,
|
|
commentCount: commentByAnn.get(row.id) ?? 0,
|
|
}));
|
|
});
|
|
}
|
|
|
|
/** Admin list of all non-archived announcements (incl. drafts/scheduled). */
|
|
export async function listAllAnnouncements(tenantId: string): Promise<AnnouncementRow[]> {
|
|
const rows = await withTenantRLS(tenantId, (tx) =>
|
|
tx.hrAnnouncement.findMany({ where: { tenantId, archivedAt: null }, orderBy: [{ pinnedAt: "desc" }, { createdAt: "desc" }] }),
|
|
);
|
|
return rows as AnnouncementRow[];
|
|
}
|
|
|
|
export async function getAnnouncementOrThrow(tenantId: string, announcementId: string): Promise<AnnouncementRow> {
|
|
const row = await withTenantRLS(tenantId, (tx) => tx.hrAnnouncement.findFirst({ where: { id: announcementId, tenantId, archivedAt: null } }));
|
|
if (!row) throw new CatalogError(foundationErrors.notFound, { announcementId });
|
|
return row as AnnouncementRow;
|
|
}
|
|
|
|
/** Mark an announcement read (idempotent). */
|
|
export async function markRead(input: { tenantId: string; announcementId: string; userId: string }): Promise<void> {
|
|
await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.hrAnnouncementRead.upsert({
|
|
where: { announcementId_userId: { announcementId: input.announcementId, userId: input.userId } },
|
|
update: {},
|
|
create: { tenantId: input.tenantId, announcementId: input.announcementId, userId: input.userId },
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Acknowledge an announcement that requires it (idempotent, audited). */
|
|
export async function acknowledge(input: { tenantId: string; announcementId: string; userId: string }): Promise<void> {
|
|
await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.hrAnnouncementRead.upsert({
|
|
where: { announcementId_userId: { announcementId: input.announcementId, userId: input.userId } },
|
|
update: { acknowledgedAt: new Date() },
|
|
create: { tenantId: input.tenantId, announcementId: input.announcementId, userId: input.userId, acknowledgedAt: new Date() },
|
|
}),
|
|
);
|
|
await audit({ tenantId: input.tenantId, actorId: input.userId, action: "HR_ANNOUNCEMENT_ACK", targetId: input.announcementId });
|
|
}
|
|
|
|
export type ReadReceiptRow = { userId: string; readAt: Date; acknowledgedAt: Date | null };
|
|
|
|
/** Manager view: who read / acknowledged an announcement. */
|
|
export async function listReadReceipts(tenantId: string, announcementId: string): Promise<ReadReceiptRow[]> {
|
|
const rows = await withTenantRLS(tenantId, (tx) =>
|
|
tx.hrAnnouncementRead.findMany({ where: { tenantId, announcementId }, select: { userId: true, createdAt: true, acknowledgedAt: true }, orderBy: { createdAt: "asc" } }),
|
|
);
|
|
return rows.map((r) => ({ userId: r.userId, readAt: r.createdAt, acknowledgedAt: r.acknowledgedAt }));
|
|
}
|
|
|
|
export type ReactionGroup = { reaction: string; count: number; mine: boolean };
|
|
|
|
/** Reactions grouped by emoji with a per-viewer `mine` flag. */
|
|
export async function getReactionGroups(tenantId: string, announcementId: string, userId: string): Promise<ReactionGroup[]> {
|
|
return withTenantRLS(tenantId, async (tx) => {
|
|
const all = await tx.hrAnnouncementReaction.findMany({ where: { tenantId, announcementId }, select: { reaction: true, userId: true } });
|
|
const map = new Map<string, { count: number; mine: boolean }>();
|
|
for (const r of all) {
|
|
const entry = map.get(r.reaction) ?? { count: 0, mine: false };
|
|
entry.count += 1;
|
|
if (r.userId === userId) entry.mine = true;
|
|
map.set(r.reaction, entry);
|
|
}
|
|
return [...map.entries()].map(([reaction, value]) => ({ reaction, count: value.count, mine: value.mine }));
|
|
});
|
|
}
|
|
|
|
/** Whether the viewer has acknowledged the announcement. */
|
|
export async function getUserAckState(tenantId: string, announcementId: string, userId: string): Promise<boolean> {
|
|
const row = await withTenantRLS(tenantId, (tx) =>
|
|
tx.hrAnnouncementRead.findUnique({ where: { announcementId_userId: { announcementId, userId } }, select: { acknowledgedAt: true } }),
|
|
);
|
|
return Boolean(row?.acknowledgedAt);
|
|
}
|
|
|
|
export async function toggleReaction(input: { tenantId: string; announcementId: string; userId: string; reaction: string }): Promise<void> {
|
|
const reaction = input.reaction.slice(0, 16);
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
const existing = await tx.hrAnnouncementReaction.findFirst({ where: { tenantId: input.tenantId, announcementId: input.announcementId, userId: input.userId, reaction } });
|
|
if (existing) {
|
|
await tx.hrAnnouncementReaction.delete({ where: { id: existing.id } });
|
|
} else {
|
|
await tx.hrAnnouncementReaction.create({ data: { tenantId: input.tenantId, announcementId: input.announcementId, userId: input.userId, reaction } });
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function addComment(input: { tenantId: string; announcementId: string; userId: string; body: string }): Promise<void> {
|
|
const body = input.body.trim();
|
|
if (!body) throw new HrServiceError("hr.error.commentEmpty");
|
|
await withTenantRLS(input.tenantId, (tx) => tx.hrAnnouncementComment.create({ data: { tenantId: input.tenantId, announcementId: input.announcementId, userId: input.userId, body: body.slice(0, MAX_COMMENT) } }));
|
|
await audit({ tenantId: input.tenantId, actorId: input.userId, action: "HR_ANNOUNCEMENT_COMMENT", targetId: input.announcementId });
|
|
}
|
|
|
|
export type CommentRow = { id: string; userId: string; body: string; createdAt: Date };
|
|
|
|
export async function listComments(tenantId: string, announcementId: string): Promise<CommentRow[]> {
|
|
const rows = await withTenantRLS(tenantId, (tx) =>
|
|
tx.hrAnnouncementComment.findMany({ where: { tenantId, announcementId, archivedAt: null }, select: { id: true, userId: true, body: true, createdAt: true }, orderBy: { createdAt: "asc" } }),
|
|
);
|
|
return rows as CommentRow[];
|
|
}
|
|
|
|
export async function archiveComment(input: { tenantId: string; commentId: string; actorUserId: string }): Promise<void> {
|
|
await withTenantRLS(input.tenantId, (tx) => tx.hrAnnouncementComment.updateMany({ where: { id: input.commentId, tenantId: input.tenantId }, data: { archivedAt: new Date() } }));
|
|
}
|