Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/hrAnnouncementService.server.ts
T

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() } }));
}