mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Add feedback voting and thumbs capture flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
3db6bdfc3c
commit
c0d0d03bce
66 changed files with 18988 additions and 78 deletions
|
|
@ -1,14 +1,18 @@
|
|||
import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
||||
companyPortabilityExportSchema,
|
||||
companyPortabilityImportSchema,
|
||||
companyPortabilityPreviewSchema,
|
||||
createCompanySchema,
|
||||
feedbackTargetTypeSchema,
|
||||
feedbackTraceStatusSchema,
|
||||
feedbackVoteValueSchema,
|
||||
updateCompanyBrandingSchema,
|
||||
updateCompanySchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { badRequest, forbidden } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
accessService,
|
||||
|
|
@ -16,6 +20,7 @@ import {
|
|||
budgetService,
|
||||
companyPortabilityService,
|
||||
companyService,
|
||||
feedbackService,
|
||||
logActivity,
|
||||
} from "../services/index.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
|
@ -28,6 +33,20 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
|||
const portability = companyPortabilityService(db, storage);
|
||||
const access = accessService(db);
|
||||
const budgets = budgetService(db);
|
||||
const feedback = feedbackService(db);
|
||||
|
||||
function parseBooleanQuery(value: unknown) {
|
||||
return value === true || value === "true" || value === "1";
|
||||
}
|
||||
|
||||
function parseDateQuery(value: unknown, field: string) {
|
||||
if (typeof value !== "string" || value.trim().length === 0) return undefined;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw badRequest(`Invalid ${field} query value`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function assertCanUpdateBranding(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
|
@ -104,6 +123,34 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
|||
res.json(company);
|
||||
});
|
||||
|
||||
router.get("/:companyId/feedback-traces", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
assertBoard(req);
|
||||
|
||||
const targetTypeRaw = typeof req.query.targetType === "string" ? req.query.targetType : undefined;
|
||||
const voteRaw = typeof req.query.vote === "string" ? req.query.vote : undefined;
|
||||
const statusRaw = typeof req.query.status === "string" ? req.query.status : undefined;
|
||||
const issueId = typeof req.query.issueId === "string" && req.query.issueId.trim().length > 0 ? req.query.issueId : undefined;
|
||||
const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim().length > 0
|
||||
? req.query.projectId
|
||||
: undefined;
|
||||
|
||||
const traces = await feedback.listFeedbackTraces({
|
||||
companyId,
|
||||
issueId,
|
||||
projectId,
|
||||
targetType: targetTypeRaw ? feedbackTargetTypeSchema.parse(targetTypeRaw) : undefined,
|
||||
vote: voteRaw ? feedbackVoteValueSchema.parse(voteRaw) : undefined,
|
||||
status: statusRaw ? feedbackTraceStatusSchema.parse(statusRaw) : undefined,
|
||||
from: parseDateQuery(req.query.from, "from"),
|
||||
to: parseDateQuery(req.query.to, "to"),
|
||||
sharedOnly: parseBooleanQuery(req.query.sharedOnly),
|
||||
includePayload: parseBooleanQuery(req.query.includePayload),
|
||||
});
|
||||
res.json(traces);
|
||||
});
|
||||
|
||||
router.post("/:companyId/export", validate(companyPortabilityExportSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
|
@ -246,6 +293,11 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
|||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const existingCompany = await svc.getById(companyId);
|
||||
if (!existingCompany) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
return;
|
||||
}
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
if (req.actor.type === "agent") {
|
||||
|
|
@ -262,6 +314,18 @@ export function companyRoutes(db: Db, storage?: StorageService) {
|
|||
} else {
|
||||
assertBoard(req);
|
||||
body = updateCompanySchema.parse(req.body);
|
||||
|
||||
if (body.feedbackDataSharingEnabled === true && !existingCompany.feedbackDataSharingEnabled) {
|
||||
body = {
|
||||
...body,
|
||||
feedbackDataSharingConsentAt: new Date(),
|
||||
feedbackDataSharingConsentByUserId: req.actor.userId ?? "local-board",
|
||||
feedbackDataSharingTermsVersion:
|
||||
typeof body.feedbackDataSharingTermsVersion === "string" && body.feedbackDataSharingTermsVersion.length > 0
|
||||
? body.feedbackDataSharingTermsVersion
|
||||
: DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const company = await svc.update(companyId, body);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import {
|
|||
createIssueLabelSchema,
|
||||
checkoutIssueSchema,
|
||||
createIssueSchema,
|
||||
feedbackTargetTypeSchema,
|
||||
feedbackTraceStatusSchema,
|
||||
feedbackVoteValueSchema,
|
||||
upsertIssueFeedbackVoteSchema,
|
||||
linkIssueApprovalSchema,
|
||||
issueDocumentKeySchema,
|
||||
restoreIssueDocumentRevisionSchema,
|
||||
|
|
@ -22,8 +26,10 @@ import {
|
|||
accessService,
|
||||
agentService,
|
||||
executionWorkspaceService,
|
||||
feedbackService,
|
||||
goalService,
|
||||
heartbeatService,
|
||||
instanceSettingsService,
|
||||
issueApprovalService,
|
||||
issueService,
|
||||
documentService,
|
||||
|
|
@ -49,6 +55,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
const svc = issueService(db);
|
||||
const access = accessService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
const feedback = feedbackService(db);
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const agentsSvc = agentService(db);
|
||||
const projectsSvc = projectService(db);
|
||||
const goalsSvc = goalService(db);
|
||||
|
|
@ -69,6 +77,19 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
};
|
||||
}
|
||||
|
||||
function parseBooleanQuery(value: unknown) {
|
||||
return value === true || value === "true" || value === "1";
|
||||
}
|
||||
|
||||
function parseDateQuery(value: unknown, field: string) {
|
||||
if (typeof value !== "string" || value.trim().length === 0) return undefined;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new HttpError(400, `Invalid ${field} query value`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function runSingleFileUpload(req: Request, res: Response) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upload.single("file")(req, res, (err: unknown) => {
|
||||
|
|
@ -542,6 +563,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
baseRevisionId: req.body.baseRevisionId ?? null,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
});
|
||||
const doc = result.document;
|
||||
|
||||
|
|
@ -1153,6 +1175,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
comment = await svc.addComment(id, commentBody, {
|
||||
agentId: actor.agentId ?? undefined,
|
||||
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
||||
runId: actor.runId,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
|
|
@ -1462,6 +1485,87 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
res.json(comment);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/feedback-votes", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Only board users can view feedback votes" });
|
||||
return;
|
||||
}
|
||||
|
||||
const votes = await feedback.listIssueVotesForUser(id, req.actor.userId ?? "local-board");
|
||||
res.json(votes);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/feedback-traces", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Only board users can view feedback traces" });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTypeRaw = typeof req.query.targetType === "string" ? req.query.targetType : undefined;
|
||||
const voteRaw = typeof req.query.vote === "string" ? req.query.vote : undefined;
|
||||
const statusRaw = typeof req.query.status === "string" ? req.query.status : undefined;
|
||||
const targetType = targetTypeRaw ? feedbackTargetTypeSchema.parse(targetTypeRaw) : undefined;
|
||||
const vote = voteRaw ? feedbackVoteValueSchema.parse(voteRaw) : undefined;
|
||||
const status = statusRaw ? feedbackTraceStatusSchema.parse(statusRaw) : undefined;
|
||||
|
||||
const traces = await feedback.listFeedbackTraces({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
targetType,
|
||||
vote,
|
||||
status,
|
||||
from: parseDateQuery(req.query.from, "from"),
|
||||
to: parseDateQuery(req.query.to, "to"),
|
||||
sharedOnly: parseBooleanQuery(req.query.sharedOnly),
|
||||
includePayload: parseBooleanQuery(req.query.includePayload),
|
||||
});
|
||||
res.json(traces);
|
||||
});
|
||||
|
||||
router.get("/feedback-traces/:traceId", async (req, res) => {
|
||||
const traceId = req.params.traceId as string;
|
||||
const trace = await feedback.getFeedbackTraceById(traceId, parseBooleanQuery(req.query.includePayload) || req.query.includePayload === undefined);
|
||||
if (!trace) {
|
||||
res.status(404).json({ error: "Feedback trace not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, trace.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Only board users can view feedback traces" });
|
||||
return;
|
||||
}
|
||||
res.json(trace);
|
||||
});
|
||||
|
||||
router.get("/feedback-traces/:traceId/bundle", async (req, res) => {
|
||||
const traceId = req.params.traceId as string;
|
||||
const bundle = await feedback.getFeedbackTraceBundle(traceId);
|
||||
if (!bundle) {
|
||||
res.status(404).json({ error: "Feedback trace not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, bundle.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Only board users can view feedback trace bundles" });
|
||||
return;
|
||||
}
|
||||
res.json(bundle);
|
||||
});
|
||||
|
||||
router.post("/issues/:id/comments", validate(addIssueCommentSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
|
@ -1539,6 +1643,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
const comment = await svc.addComment(id, req.body.body, {
|
||||
agentId: actor.agentId ?? undefined,
|
||||
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
||||
runId: actor.runId,
|
||||
});
|
||||
|
||||
if (actor.runId) {
|
||||
|
|
@ -1660,6 +1765,93 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
res.status(201).json(comment);
|
||||
});
|
||||
|
||||
router.post("/issues/:id/feedback-votes", validate(upsertIssueFeedbackVoteSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Only board users can vote on AI feedback" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const result = await feedback.saveIssueVote({
|
||||
issueId: id,
|
||||
targetType: req.body.targetType,
|
||||
targetId: req.body.targetId,
|
||||
vote: req.body.vote,
|
||||
reason: req.body.reason,
|
||||
authorUserId: req.actor.userId ?? "local-board",
|
||||
allowSharing: req.body.allowSharing === true,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.feedback_vote_saved",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
targetType: result.vote.targetType,
|
||||
targetId: result.vote.targetId,
|
||||
vote: result.vote.vote,
|
||||
hasReason: Boolean(result.vote.reason),
|
||||
sharingEnabled: result.sharingEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.consentEnabledNow) {
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.feedback_data_sharing_updated",
|
||||
entityType: "company",
|
||||
entityId: issue.companyId,
|
||||
details: {
|
||||
feedbackDataSharingEnabled: true,
|
||||
source: "issue_feedback_vote",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (result.persistedSharingPreference) {
|
||||
const settings = await instanceSettings.get();
|
||||
const companyIds = await instanceSettings.listCompanyIds();
|
||||
await Promise.all(
|
||||
companyIds.map((companyId) =>
|
||||
logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "instance.settings.general_updated",
|
||||
entityType: "instance_settings",
|
||||
entityId: settings.id,
|
||||
details: {
|
||||
general: settings.general,
|
||||
changedKeys: ["feedbackDataSharingPreference"],
|
||||
source: "issue_feedback_vote",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json(result.vote);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/attachments", async (req, res) => {
|
||||
const issueId = req.params.id as string;
|
||||
const issue = await svc.getById(issueId);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue