docs(05): create Phase 5 Offer System plans — 4 plans in 3 waves
Wave 1: 05-01 schema migration (5 new tables, additive only) Wave 2: 05-02 offer catalog CRUD (/admin/offers + NavBar) Wave 3: 05-03 project offer assignment (OffersTab) + 05-04 client dashboard offers + /admin/forecast Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
---
|
||||
plan_id: 05-03
|
||||
phase: 5
|
||||
wave: 3
|
||||
title: "Project offer assignment — OffersTab in project workspace + offer queries extension"
|
||||
type: execute
|
||||
depends_on: [05-01, 05-02]
|
||||
files_modified:
|
||||
- src/lib/admin-queries.ts
|
||||
- src/app/admin/projects/[id]/page.tsx
|
||||
- src/app/admin/projects/project-actions.ts
|
||||
- src/components/admin/tabs/OffersTab.tsx
|
||||
requirements_addressed: [OFFER-04]
|
||||
autonomous: true
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin can assign a micro-offer to a project from the project workspace Offerte tab"
|
||||
- "The Offerte tab shows all active offers assigned to a project with micro name, duration, and accepted_total"
|
||||
- "Admin can set the accepted_total on a project offer assignment"
|
||||
- "ProjectFullDetail type includes projectOffers array"
|
||||
artifacts:
|
||||
- path: "src/components/admin/tabs/OffersTab.tsx"
|
||||
provides: "Client component for assigning and viewing project offers"
|
||||
contains: "assign form, offer list, accepted_total input"
|
||||
- path: "src/app/admin/projects/project-actions.ts"
|
||||
provides: "Server actions for project offer assignment mutations"
|
||||
contains: "assignOfferToProject, removeProjectOffer, updateProjectOfferTotal"
|
||||
key_links:
|
||||
- from: "src/app/admin/projects/[id]/page.tsx"
|
||||
to: "src/lib/admin-queries.ts"
|
||||
via: "getProjectFullDetail() — extended to include projectOffers"
|
||||
pattern: "projectOffers"
|
||||
- from: "src/components/admin/tabs/OffersTab.tsx"
|
||||
to: "src/app/admin/projects/project-actions.ts"
|
||||
via: "assignOfferToProject server action"
|
||||
pattern: "assignOfferToProject"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add an "Offerte" tab to the project workspace at `/admin/projects/[id]` that lets the admin assign micro-offers to a project, set the offer-level accepted total, and view all active assignments.
|
||||
|
||||
Purpose: This is the assignment layer — it connects the offer catalog (Plan 02) to specific projects. Without this, offers exist in the catalog but cannot be associated with client work.
|
||||
Output: `OffersTab.tsx` component; project-actions.ts extended with offer actions; `getProjectFullDetail` extended to return `projectOffers`; `/admin/projects/[id]` page updated with the new tab.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-RESEARCH.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-01-SUMMARY.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From src/lib/admin-queries.ts — ProjectFullDetail type to extend: -->
|
||||
```typescript
|
||||
export type ProjectFullDetail = {
|
||||
project: Project & { client: { id: string; name: string; brand_name: string; slug: string | null } };
|
||||
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||
payments: Payment[];
|
||||
documents: Document[];
|
||||
notes: Note[];
|
||||
comments: Comment[];
|
||||
quoteItems: QuoteItemWithLabel[];
|
||||
activeServices: ServiceCatalog[];
|
||||
activeTimerEntryId: string | null;
|
||||
activeTimerStartedAt: Date | null;
|
||||
totalTrackedSeconds: number;
|
||||
// ADD:
|
||||
// projectOffers: ProjectOfferWithMicro[];
|
||||
// availableMicros: OfferMicro[];
|
||||
};
|
||||
```
|
||||
|
||||
<!-- From src/app/admin/projects/[id]/page.tsx — Tabs structure to extend: -->
|
||||
```tsx
|
||||
// Existing tabs: phases | payments | documents | notes | comments | quote | timer
|
||||
// ADD: <TabsTrigger value="offers">Offerte</TabsTrigger>
|
||||
// ADD: <TabsContent value="offers"><OffersTab ... /></TabsContent>
|
||||
```
|
||||
|
||||
<!-- From src/db/schema.ts (after 05-01): -->
|
||||
```typescript
|
||||
export const project_offers = pgTable("project_offers", {
|
||||
id: text("id").primaryKey(),
|
||||
project_id: text("project_id").notNull(), // FK → projects
|
||||
micro_id: text("micro_id").notNull(), // FK → offer_micros (RESTRICT on delete)
|
||||
start_date: timestamp(...).notNull().defaultNow(),
|
||||
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }), // nullable
|
||||
created_at: timestamp(...).notNull().defaultNow(),
|
||||
});
|
||||
export type ProjectOffer = typeof project_offers.$inferSelect;
|
||||
export type OfferMicro = typeof offer_micros.$inferSelect;
|
||||
```
|
||||
|
||||
<!-- From src/app/admin/catalog/actions.ts — pattern for project-actions.ts additions: -->
|
||||
```typescript
|
||||
"use server";
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
// revalidatePath(`/admin/projects/${projectId}`);
|
||||
// revalidatePath("/admin/forecast");
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Extend getProjectFullDetail + add project offer server actions</name>
|
||||
<files>
|
||||
src/lib/admin-queries.ts
|
||||
src/app/admin/projects/project-actions.ts
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- src/lib/admin-queries.ts — read the FULL file; understand ProjectFullDetail type and getProjectFullDetail function
|
||||
- src/app/admin/projects/project-actions.ts — read the FULL file; understand existing action patterns
|
||||
- src/db/schema.ts — confirm project_offers, offer_micros column names after 05-01
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**Extend `src/lib/admin-queries.ts`:**
|
||||
|
||||
1. Add imports for new offer tables at the top of the import block:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
// existing imports...
|
||||
offer_micros, project_offers,
|
||||
} from "@/db/schema";
|
||||
import type {
|
||||
// existing types...
|
||||
OfferMicro, ProjectOffer,
|
||||
} from "@/db/schema";
|
||||
```
|
||||
|
||||
2. Add a new type after the existing types:
|
||||
|
||||
```typescript
|
||||
export type ProjectOfferWithMicro = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
micro_id: string;
|
||||
micro_internal_name: string;
|
||||
micro_public_name: string;
|
||||
micro_duration_months: number;
|
||||
start_date: Date;
|
||||
accepted_total: string | null;
|
||||
created_at: Date;
|
||||
};
|
||||
```
|
||||
|
||||
3. Extend `ProjectFullDetail` type — add two fields at the end of the type definition:
|
||||
|
||||
```typescript
|
||||
projectOffers: ProjectOfferWithMicro[];
|
||||
availableMicros: Array<{ id: string; internal_name: string; public_name: string; duration_months: number }>;
|
||||
```
|
||||
|
||||
4. In the `getProjectFullDetail` function, extend the parallel `Promise.all` array to include two new queries alongside the existing ones. Add them to the destructuring and return. The existing `Promise.all` is at line ~491; add two new queries to the array:
|
||||
|
||||
```typescript
|
||||
// Add these two to the Promise.all:
|
||||
|
||||
// Query A: project offers for this project joined with micro info
|
||||
db
|
||||
.select({
|
||||
id: project_offers.id,
|
||||
project_id: project_offers.project_id,
|
||||
micro_id: project_offers.micro_id,
|
||||
micro_internal_name: offer_micros.internal_name,
|
||||
micro_public_name: offer_micros.public_name,
|
||||
micro_duration_months: offer_micros.duration_months,
|
||||
start_date: project_offers.start_date,
|
||||
accepted_total: project_offers.accepted_total,
|
||||
created_at: project_offers.created_at,
|
||||
})
|
||||
.from(project_offers)
|
||||
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
||||
.where(eq(project_offers.project_id, id))
|
||||
.orderBy(asc(project_offers.created_at)),
|
||||
|
||||
// Query B: all active micro-offers (for the assignment dropdown)
|
||||
db
|
||||
.select({
|
||||
id: offer_micros.id,
|
||||
internal_name: offer_micros.internal_name,
|
||||
public_name: offer_micros.public_name,
|
||||
duration_months: offer_micros.duration_months,
|
||||
})
|
||||
.from(offer_micros)
|
||||
.orderBy(asc(offer_micros.internal_name)),
|
||||
```
|
||||
|
||||
Destructure the two new results from `Promise.all` as `projectOffersRows` and `availableMicrosRows`. Add them to the return object:
|
||||
|
||||
```typescript
|
||||
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
||||
availableMicros: availableMicrosRows,
|
||||
```
|
||||
|
||||
**Extend `src/app/admin/projects/project-actions.ts`:**
|
||||
|
||||
Add these three new server actions at the end of the file. Copy the `requireAdmin()` pattern exactly as it appears in the existing file:
|
||||
|
||||
```typescript
|
||||
// ── Offer assignment actions ─────────────────────────────────────────────────
|
||||
|
||||
import { project_offers } from "@/db/schema"; // add to existing imports at top
|
||||
|
||||
const assignOfferSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
micro_id: z.string().min(1, "Seleziona una micro-offerta"),
|
||||
accepted_total: z.coerce.number().min(0).optional(),
|
||||
});
|
||||
|
||||
export async function assignOfferToProject(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const parsed = assignOfferSchema.safeParse({
|
||||
project_id: formData.get("project_id"),
|
||||
micro_id: formData.get("micro_id"),
|
||||
accepted_total: formData.get("accepted_total") || undefined,
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
await db.insert(project_offers).values({
|
||||
project_id: parsed.data.project_id,
|
||||
micro_id: parsed.data.micro_id,
|
||||
accepted_total: parsed.data.accepted_total !== undefined
|
||||
? parsed.data.accepted_total.toFixed(2)
|
||||
: null,
|
||||
});
|
||||
revalidatePath(`/admin/projects/${parsed.data.project_id}`);
|
||||
revalidatePath("/admin/forecast");
|
||||
}
|
||||
|
||||
export async function removeProjectOffer(projectOfferId: string, projectId: string) {
|
||||
await requireAdmin();
|
||||
await db.delete(project_offers).where(eq(project_offers.id, projectOfferId));
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
revalidatePath("/admin/forecast");
|
||||
}
|
||||
|
||||
export async function updateProjectOfferTotal(
|
||||
projectOfferId: string,
|
||||
projectId: string,
|
||||
accepted_total: string
|
||||
) {
|
||||
await requireAdmin();
|
||||
const amount = parseFloat(accepted_total);
|
||||
if (isNaN(amount) || amount < 0) throw new Error("Importo non valido");
|
||||
await db
|
||||
.update(project_offers)
|
||||
.set({ accepted_total: amount.toFixed(2) })
|
||||
.where(eq(project_offers.id, projectOfferId));
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
revalidatePath("/admin/forecast");
|
||||
}
|
||||
```
|
||||
|
||||
Note: `project-actions.ts` already imports `z`, `revalidatePath`, `db`, `eq`, and `requireAdmin()` — check the existing imports and add only what is missing (`project_offers` table import).
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- `grep "projectOffers\|availableMicros" src/lib/admin-queries.ts` returns at least 4 matches (type definition + return)
|
||||
- `grep "assignOfferToProject\|removeProjectOffer\|updateProjectOfferTotal" src/app/admin/projects/project-actions.ts` returns 3 matches
|
||||
- `grep "revalidatePath.*forecast" src/app/admin/projects/project-actions.ts` returns at least 3 matches (one per offer action)
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>admin-queries.ts extended with projectOffers field; project-actions.ts has three new offer actions; TypeScript compiles</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: OffersTab component + project workspace page update</name>
|
||||
<files>
|
||||
src/components/admin/tabs/OffersTab.tsx
|
||||
src/app/admin/projects/[id]/page.tsx
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- src/app/admin/projects/[id]/page.tsx — read FULL file before editing (understand current Tabs structure, destructuring, imports)
|
||||
- src/components/admin/tabs/QuoteTab.tsx — read first 40 lines for component prop pattern (not logic)
|
||||
- src/lib/admin-queries.ts — confirm ProjectOfferWithMicro and availableMicros types (just extended in Task 1)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**Create `src/components/admin/tabs/OffersTab.tsx`:**
|
||||
|
||||
This is a `"use client"` component that handles assignment form submission and inline accepted_total editing.
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useTransition, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
assignOfferToProject,
|
||||
removeProjectOffer,
|
||||
updateProjectOfferTotal,
|
||||
} from "@/app/admin/projects/project-actions";
|
||||
import type { ProjectOfferWithMicro } from "@/lib/admin-queries";
|
||||
|
||||
type AvailableMicro = {
|
||||
id: string;
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
duration_months: number;
|
||||
};
|
||||
|
||||
interface OffersTabProps {
|
||||
projectId: string;
|
||||
projectOffers: ProjectOfferWithMicro[];
|
||||
availableMicros: AvailableMicro[];
|
||||
}
|
||||
|
||||
export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
function handleAssign(formData: FormData) {
|
||||
startTransition(async () => {
|
||||
await assignOfferToProject(formData);
|
||||
formRef.current?.reset();
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemove(offerId: string) {
|
||||
startTransition(async () => {
|
||||
await removeProjectOffer(offerId, projectId);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function handleTotalUpdate(offerId: string, value: string) {
|
||||
startTransition(async () => {
|
||||
await updateProjectOfferTotal(offerId, projectId, value);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Active assignments */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
|
||||
{projectOffers.length === 0 ? (
|
||||
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{projectOffers.map((offer) => (
|
||||
<div
|
||||
key={offer.id}
|
||||
className="bg-white rounded-lg border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{offer.micro_internal_name}</p>
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Pubblico: {offer.micro_public_name} · {offer.micro_duration_months}{" "}
|
||||
{offer.micro_duration_months === 1 ? "mese" : "mesi"}
|
||||
</p>
|
||||
<p className="text-xs text-[#71717a] mt-1">
|
||||
Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}
|
||||
</p>
|
||||
{/* Accepted total inline edit */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<label className="text-xs text-[#71717a]">Totale accettato €:</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={offer.accepted_total ?? ""}
|
||||
placeholder="0.00"
|
||||
onBlur={(e) => {
|
||||
const val = e.currentTarget.value.trim();
|
||||
if (val !== (offer.accepted_total ?? "")) {
|
||||
handleTotalUpdate(offer.id, val);
|
||||
}
|
||||
}}
|
||||
className="w-24 border rounded px-2 py-0.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(offer.id)}
|
||||
disabled={isPending}
|
||||
className="text-xs text-red-600 hover:underline shrink-0"
|
||||
>
|
||||
Rimuovi
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assignment form */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Micro-Offerta</h3>
|
||||
<form
|
||||
ref={formRef}
|
||||
action={handleAssign}
|
||||
className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3"
|
||||
>
|
||||
<input type="hidden" name="project_id" value={projectId} />
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-[#71717a] block mb-1">Micro-offerta</label>
|
||||
<select
|
||||
name="micro_id"
|
||||
required
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">Seleziona micro-offerta...</option>
|
||||
{availableMicros.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.internal_name} ({m.duration_months}{" "}
|
||||
{m.duration_months === 1 ? "mese" : "mesi"})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-[#71717a] block mb-1">Totale accettato € (opzionale)</label>
|
||||
<input
|
||||
name="accepted_total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || availableMicros.length === 0}
|
||||
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31] disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Salvataggio..." : "Assegna Offerta"}
|
||||
</button>
|
||||
|
||||
{availableMicros.length === 0 && (
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Nessuna micro-offerta disponibile. Crea prima una micro-offerta in{" "}
|
||||
<a href="/admin/offers" className="underline">Offerte</a>.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Update `src/app/admin/projects/[id]/page.tsx`:**
|
||||
|
||||
Read the full file. Make these three targeted changes:
|
||||
|
||||
1. Add import for `OffersTab` at the top of the imports:
|
||||
```typescript
|
||||
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
||||
```
|
||||
|
||||
2. Destructure the two new fields from `detail`:
|
||||
```typescript
|
||||
// Add to the existing destructuring:
|
||||
const {
|
||||
// ...existing fields...
|
||||
projectOffers,
|
||||
availableMicros,
|
||||
} = detail;
|
||||
```
|
||||
|
||||
3. Add the Offerte tab trigger and content inside the `<Tabs>` component, after the `<TabsTrigger value="timer">Timer</TabsTrigger>` line:
|
||||
```tsx
|
||||
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
||||
```
|
||||
And after the last `<TabsContent>`:
|
||||
```tsx
|
||||
<TabsContent value="offers">
|
||||
<OffersTab
|
||||
projectId={id}
|
||||
projectOffers={projectOffers}
|
||||
availableMicros={availableMicros}
|
||||
/>
|
||||
</TabsContent>
|
||||
```
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- `grep "OffersTab" src/app/admin/projects/[id]/page.tsx` returns 2 matches (import + usage)
|
||||
- `grep "projectOffers\|availableMicros" src/app/admin/projects/[id]/page.tsx` returns at least 2 matches
|
||||
- File `src/components/admin/tabs/OffersTab.tsx` exists
|
||||
- `grep "assignOfferToProject\|removeProjectOffer\|updateProjectOfferTotal" src/components/admin/tabs/OffersTab.tsx` returns 3 matches
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>OffersTab created; project workspace page has Offerte tab; admin can assign micro-offers and set accepted_total; TypeScript compiles</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → project-actions.ts | Admin assigns offers to projects via server actions |
|
||||
| Admin session → project_offers mutations | Unauthenticated access must be rejected |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-05-06 | Elevation of Privilege | assignOfferToProject, removeProjectOffer, updateProjectOfferTotal | mitigate | `requireAdmin()` as first call in each action |
|
||||
| T-05-07 | Tampering | removeProjectOffer — deletes project_offer row | mitigate | Action requires projectId param for revalidatePath only; deletion targets by projectOfferId (PK); no bulk delete possible |
|
||||
| T-05-08 | Information Disclosure | OffersTab renders micro internal_name | accept | Tab is under /admin/projects/* — Auth.js session guard; internal_name exposure to admin is intentional and correct |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After both tasks complete:
|
||||
- `npx tsc --noEmit` exits 0
|
||||
- Visit `/admin/projects/[id]` → Offerte tab is visible
|
||||
- Offerte tab shows empty state when no offers assigned
|
||||
- Assign a micro-offer → appears in the active list
|
||||
- Set accepted_total by blurring the input → value persists on refresh
|
||||
- Remove an assignment → disappears from list
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. Admin can assign a micro-offer to a project with optional accepted_total
|
||||
2. Project workspace Offerte tab lists active assignments with micro name, duration, accepted_total
|
||||
3. Admin can update the accepted_total inline (onBlur save)
|
||||
4. Admin can remove a project offer assignment
|
||||
5. All changes revalidate /admin/forecast path
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/05-offer-system/05-03-SUMMARY.md` using the summary template.
|
||||
</output>
|
||||
Reference in New Issue
Block a user