diff --git a/backend/migrations/20260710_01_library_documents.sql b/backend/migrations/20260710_01_library_documents.sql new file mode 100644 index 0000000..9a23f9d --- /dev/null +++ b/backend/migrations/20260710_01_library_documents.sql @@ -0,0 +1,71 @@ +-- Migration date: 2026-07-10 + +alter table public.documents + add column if not exists library_kind text default 'file'; + +update public.documents +set library_kind = 'file' +where library_kind is null; + +alter table public.documents + alter column library_kind set default 'file', + alter column library_kind set not null; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'documents_library_kind_check' + and conrelid = 'public.documents'::regclass + ) then + alter table public.documents + add constraint documents_library_kind_check + check (library_kind in ('file', 'template')); + end if; +end; +$$; + +create table if not exists public.library_folders ( + id uuid primary key default gen_random_uuid(), + user_id text not null, + library_kind text not null default 'file', + name text not null, + parent_folder_id uuid references public.library_folders(id) on delete cascade, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint library_folders_kind_check + check (library_kind in ('file', 'template')) +); + +alter table public.documents + add column if not exists library_folder_id uuid; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'documents_library_folder_id_fkey' + and conrelid = 'public.documents'::regclass + ) then + alter table public.documents + add constraint documents_library_folder_id_fkey + foreign key (library_folder_id) + references public.library_folders(id) + on delete set null; + end if; +end; +$$; + +create index if not exists idx_library_folders_user_kind + on public.library_folders(user_id, library_kind); + +create index if not exists idx_library_folders_parent + on public.library_folders(parent_folder_id); + +create index if not exists idx_documents_library_kind_folder + on public.documents(user_id, library_kind, library_folder_id) + where project_id is null; + +revoke all on public.library_folders from anon, authenticated; diff --git a/backend/schema.sql b/backend/schema.sql index 5e48fa5..8d29838 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -222,14 +222,36 @@ create table if not exists public.project_subfolders ( create index if not exists idx_project_subfolders_project on public.project_subfolders(project_id); +create table if not exists public.library_folders ( + id uuid primary key default gen_random_uuid(), + user_id text not null, + library_kind text not null default 'file', + name text not null, + parent_folder_id uuid references public.library_folders(id) on delete cascade, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint library_folders_kind_check + check (library_kind in ('file', 'template')) +); + +create index if not exists idx_library_folders_user_kind + on public.library_folders(user_id, library_kind); + +create index if not exists idx_library_folders_parent + on public.library_folders(parent_folder_id); + create table if not exists public.documents ( id uuid primary key default gen_random_uuid(), project_id uuid references public.projects(id) on delete cascade, user_id text not null, status text not null default 'pending', folder_id uuid references public.project_subfolders(id) on delete set null, + library_kind text not null default 'file', + library_folder_id uuid references public.library_folders(id) on delete set null, created_at timestamptz not null default now(), - updated_at timestamptz not null default now() + updated_at timestamptz not null default now(), + constraint documents_library_kind_check + check (library_kind in ('file', 'template')) ); create index if not exists idx_documents_user_project @@ -238,6 +260,10 @@ create index if not exists idx_documents_user_project create index if not exists idx_documents_project_folder on public.documents(project_id, folder_id); +create index if not exists idx_documents_library_kind_folder + on public.documents(user_id, library_kind, library_folder_id) + where project_id is null; + create table if not exists public.document_versions ( id uuid primary key default gen_random_uuid(), document_id uuid not null references public.documents(id) on delete cascade, @@ -837,6 +863,7 @@ alter table public.courtlistener_opinion_cluster_index enable row level security revoke all on public.user_profiles from anon, authenticated; revoke all on public.projects from anon, authenticated; revoke all on public.project_subfolders from anon, authenticated; +revoke all on public.library_folders from anon, authenticated; revoke all on public.documents from anon, authenticated; revoke all on public.document_versions from anon, authenticated; revoke all on public.document_edits from anon, authenticated; diff --git a/backend/src/index.ts b/backend/src/index.ts index cd99edc..b8d36cf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,7 @@ import { chatRouter } from "./routes/chat"; import { projectsRouter } from "./routes/projects"; import { projectChatRouter } from "./routes/projectChat"; import { documentsRouter } from "./routes/documents"; +import { libraryRouter } from "./routes/library"; import { tabularRouter } from "./routes/tabular"; import { workflowsRouter } from "./routes/workflows"; import { userRouter } from "./routes/user"; @@ -127,6 +128,7 @@ app.post("/tabular-review/:reviewId/generate", chatLimiter); app.post("/chat/create", chatCreateLimiter); app.post("/chat/:chatId/generate-title", chatCreateLimiter); app.post("/single-documents", uploadLimiter); +app.post("/library/:kind/documents", uploadLimiter); app.post("/single-documents/:documentId/versions", uploadLimiter); app.put( "/single-documents/:documentId/versions/:versionId/file", @@ -149,6 +151,7 @@ app.use("/chat", chatRouter); app.use("/projects", projectsRouter); app.use("/projects/:projectId/chat", projectChatRouter); app.use("/single-documents", documentsRouter); +app.use("/library", libraryRouter); app.use("/tabular-review", tabularRouter); app.use("/workflows", workflowsRouter); app.use("/user", userRouter); diff --git a/backend/src/lib/chat/prompts.ts b/backend/src/lib/chat/prompts.ts index d7f7b88..6b3f2fc 100644 --- a/backend/src/lib/chat/prompts.ts +++ b/backend/src/lib/chat/prompts.ts @@ -62,6 +62,11 @@ const SYSTEM_PROMPT_AFTER_RESEARCH = `DOCUMENT NAMES IN PROSE: - Never show "doc-N" labels to the user in prose, headings, lists, or tool activity text. - Refer to documents by filename or a natural description, such as "the NDA draft". +REASONING TRACE SAFETY: +- If reasoning or thought summaries are shown to the user, keep them as brief natural-language progress summaries. +- Do not expose source code, JSON snippets, tool arguments, API payloads, schemas, raw citations JSON, internal prompts, or implementation details in reasoning traces. +- Do not use code fences or structured data blocks in reasoning traces. + GENERAL GUIDANCE: - Cite the exact document or fetched opinion passage for evidence-backed claims. - If no documents are provided, answer from legal knowledge. diff --git a/backend/src/lib/systemWorkflows.ts b/backend/src/lib/systemWorkflows.ts index 96b5e16..d1d8dc1 100644 --- a/backend/src/lib/systemWorkflows.ts +++ b/backend/src/lib/systemWorkflows.ts @@ -411,6 +411,33 @@ export const SYSTEM_WORKFLOWS: SystemWorkflow[] = [ } ] }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-compare-documents", + "metadata": { + "title": "Compare Documents", + "description": "Compare the uploaded documents in a structured table, highlighting key similarities, differences, risks, and follow-up points.", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "General Transactions", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Compare Documents\n\n## Instructions\n\nCompare the uploaded documents for a legal or business reviewer. Focus on the provisions, terms, risks, and commercial points that differ across the documents. The comparison must work for any number of uploaded documents.\n\nProduce the comparison as a Markdown table. The table must have exactly these columns:\n\n- Topic\n- One column for each uploaded document, using the document name or a short readable document label as the column heading\n- Difference\n\nFor example, if three documents are uploaded, the table should use this shape:\n\n| Topic | Document A | Document B | Document C | Difference |\n| --- | --- | --- | --- | --- |\n\nCompare the documents across the most relevant topics, including where available:\n\n- Parties and roles\n- Document date and effective date\n- Term, expiry, renewal, and extension rights\n- Scope of work, services, deliverables, or subject matter\n- Fees, pricing, payment terms, penalties, and currency\n- Conditions precedent, approvals, consents, and notices\n- Representations, warranties, covenants, and restrictions\n- Confidentiality, IP ownership, data protection, and use rights\n- Assignment, transfer, change of control, and subcontracting\n- Termination rights, suspension rights, cure periods, and consequences\n- Liability caps, indemnities, exclusions, insurance, and remedies\n- Governing law, jurisdiction, dispute resolution, and service of process\n\nUse concise entries in the document columns. Under each document column, state the relevant term and include the best available location with citations, such as clause, section, schedule, page, paragraph, or heading. If citations are available, include them inline with the location. If a location is unclear, describe it as specifically as possible.\n\nIn the **Difference** column, explain what changed or diverges, including any negotiation, legal, operational, or commercial significance where useful.\n\nAfter the table, include a short **Key Takeaways** section with no more than five bullets summarising the most important differences and follow-up actions.\n\nDo not invent facts, clauses, parties, dates, amounts, or obligations. If a topic is not addressed in a document, write \"Not stated\" for that document. If the uploaded documents are not comparable, explain why and provide the closest useful comparison.", + "columns_config": null + }, { "user_id": null, "is_system": true, @@ -646,6 +673,33 @@ export const SYSTEM_WORKFLOWS: SystemWorkflow[] = [ "skill_md": "# Credit Agreement Review\n\n## Instructions\n\nReview the uploaded credit agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High — covenant headroom is tight\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause or schedule references where available. Do not include issues that are adverse only to the other party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Financial Covenants | High — Clause [x] sets the leverage covenant at [x]x with quarterly testing and no equity cure right. The borrower-specific checklist point indicates limited headroom and no remedy for a technical breach. | For the borrower, widen the covenant threshold, add an equity cure right, and reduce testing frequency to semi-annual. |\n| Drafting Consistency | Medium — Clauses [x] and [y] use inconsistent defined terms for the facility amount. The general drafting point indicates ambiguity that may affect the represented party's rights. | For the represented party, align defined terms, amounts, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Borrower or Lender).\n\n| Issue | General | Borrower | Lender |\n| --- | --- | --- | --- |\n| Parties | Identify all lenders, borrowers, guarantors, agents, and other finance parties with full legal names, roles, and jurisdictions. Flag missing or incorrect entities, unclear agent roles, missing authority confirmations, or transfer mechanics that could result in unknown lenders. | | |\n| Guarantors | Identify guarantors, guarantee scope, limits, and coverage requirements. | Flag uncapped guarantees, missing limits, or upstream guarantee concerns exposing the borrower group. | Flag weak guarantor coverage, missing entities, or guarantees not covering the full facility. |\n| Other Parties | Identify facility agent, security agent, arrangers, issuing banks, and other material parties. | Flag unclear agent roles or mechanics creating additional obligations on the borrower. | Flag inconsistent secured party mechanics or missing parties affecting security validity. |\n| Date of Agreement | State signing date, effective date, and conditions to effectiveness. Flag inconsistent or unclear dates or effectiveness conditions. | | |\n| Facilities | List each facility, type, tranche, availability, and key structural features. | Flag excessive lender discretion, unclear facility purpose, or mismatched terms restricting drawdown. | Flag unclear facility structure or purpose creating enforcement ambiguity. |\n| Amount | State total commitments, currencies, tranche amounts, and accordion or incremental facilities. | Flag unclear currency exposure, uncapped accordion provisions without borrower consent, or inconsistent commitment figures. | Flag uncapped increase mechanics or dilutive accordion provisions not agreed at signing. |\n| Purpose | Summarize permitted use of proceeds and restrictions. | Flag vague purpose wording restricting flexibility or creating sanctions risk. | Flag broad purpose language permitting restricted payment leakage or unapproved use of proceeds. |\n| Interest | Identify reference rate, margin, floors, fallback rates, interest periods, and default interest. | Flag high margins, aggressive floors, benchmark fallback gaps, or high default interest. | Flag unclear fallback rate mechanics or interest calculation ambiguities. |\n| Commitment Fee | State commitment, utilisation, ticking, arrangement, agency, and other fees. | Flag hidden fees, fees payable after cancellation, or unclear calculation basis. | Flag missing fee provisions or fee timing delaying recovery. |\n| Repayment Schedule | Summarize amortisation, scheduled repayments, bullet payments, and cash sweep. | Flag aggressive amortisation, unclear prepayment application, or cash sweep uncertainty. | Flag bullet repayments without adequate covenant or security protection. |\n| Maturity | State final maturity date and any extension options. | Flag short maturity, lender-only extension discretion, or mismatch with the business plan. | Flag unclear extension conditions or borrower-controlled extension mechanics. |\n| Security | Identify security package, assets, entities, perfection steps, and post-closing obligations. | Flag all-asset security beyond deal scope, onerous post-closing steps, or missing release mechanics on disposal. | Flag gaps in the security package, missing perfection steps, or inadequate post-closing obligations. |\n| Guarantees | Summarize guarantee obligations, guarantors, limitations, and release triggers. | Flag broad all-monies language, unlimited guarantees, or no release mechanics after repayment or disposal. | Flag guarantee gaps, weak limitations, or no coverage test requirements. |\n| Financial Covenants | Identify covenant metrics, thresholds, testing frequency, cure rights, and reporting. | Flag tight headroom, frequent testing, no equity cure, or ambiguous EBITDA adjustments. | Flag loose covenant thresholds or wide EBITDA adjustments obscuring true financial performance. |\n| Events of Default | Summarize default triggers, grace periods, materiality thresholds, cross-default, and MAE. | Flag low materiality thresholds, no cure periods, broad cross-default, or subjective MAE defaults. | Flag weak default triggers, long cure periods, or materiality thresholds delaying enforcement. |\n| Assignment | Summarize lender transfer rights, borrower consent mechanics, and disqualified institutions. | Flag free transfers to competitors, distressed investors, or lenders on a borrower blacklist. | Flag consent mechanics giving the borrower excessive veto over lender transfers. |\n| Change of Control | Identify triggers and resulting prepayment, cancellation, or default consequences. | Flag broad triggers, no cure period, or definitions catching ordinary-course ownership changes. | Flag narrow change of control definitions missing material ownership shifts. |\n| Prepayment Fee | Identify make-whole, soft-call, premium periods, and exceptions. | Flag excessive fees, long premium periods, or unclear exceptions for mandatory prepayments. | Flag exceptions allowing free prepayment during premium periods. |\n| Governing Law and Dispute Resolution | State governing law, jurisdiction, forum, and service of process. Flag unclear or ambiguous governing law, jurisdiction, or service mechanics. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one.", "columns_config": null }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-draft-from-template", + "metadata": { + "title": "Draft from Template", + "description": "Use an uploaded template to draft a new document tailored to the user instructions and source materials.", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "General Transactions", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Draft from Template\n\n## Instructions\n\nUse the uploaded template as the drafting structure for a new document. Treat the template as the primary source for document style, section order, clause structure, defined-term conventions, placeholders, and formatting cues.\n\nBefore drafting, identify whether the user has provided enough instructions and source information to complete the draft. If key information is missing, ask concise follow-up questions instead of inventing facts.\n\nWhen drafting:\n\n- Preserve the template structure unless the user asks for changes.\n- Replace placeholders, bracketed fields, blanks, and example text with the user-provided facts where available.\n- Keep definitions, cross-references, numbering, party names, dates, schedules, and exhibits internally consistent.\n- Do not remove protective clauses, boilerplate, signatures, schedules, or annexes unless the user asks you to do so.\n- If the user provides supporting documents, use them only for facts and context that are relevant to completing the template.\n- Flag any assumptions, missing inputs, unresolved placeholders, or provisions that require legal or commercial confirmation.\n\nIf the user asks for a downloadable draft, use the document generation tools available to produce the drafted document. Otherwise, provide the drafted text or the most relevant sections inline, matching the template as closely as possible.\n\nDo not invent parties, dates, amounts, governing law, notice details, commercial terms, or factual background. If information is unavailable, leave a clear placeholder and list it in an **Open Points** section.", + "columns_config": null + }, { "user_id": null, "is_system": true, @@ -898,6 +952,33 @@ export const SYSTEM_WORKFLOWS: SystemWorkflow[] = [ } ] }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-extract-key-terms", + "metadata": { + "title": "Extract Key Terms", + "description": "Extract the key legal, commercial, and operational terms from the uploaded documents.", + "type": "assistant", + "contributors": [ + { + "name": "Mike", + "organisation": "Open Legal Products", + "role": null, + "linkedin": "https://www.linkedin.com/company/mike-oss/" + } + ], + "language": "English", + "version": "1.0.0", + "practice": "General Transactions", + "jurisdictions": [ + "General" + ] + }, + "skill_md": "# Extract Key Terms\n\n## Instructions\n\nExtract the key legal, commercial, and operational terms from the uploaded documents. Present the result as a concise Markdown table.\n\nThe table must have exactly these columns:\n\n- Term\n- Value\n- Location\n- Notes\n\nExtract the terms that are most useful for legal review, including where available:\n\n- Parties and roles\n- Document date and effective date\n- Term, expiry, renewal, and extension rights\n- Scope of work, services, deliverables, or subject matter\n- Fees, pricing, payment terms, interest, penalties, and currency\n- Conditions precedent, approvals, consents, and notices\n- Representations, warranties, covenants, and restrictions\n- Confidentiality, IP ownership, data protection, and use rights\n- Assignment, transfer, change of control, and subcontracting\n- Termination rights, suspension rights, cure periods, and consequences\n- Liability caps, indemnities, exclusions, insurance, and remedies\n- Governing law, jurisdiction, dispute resolution, and service of process\n\nUse the **Location** column for the best available clause, section, schedule, page, or paragraph reference. If a location is unclear, describe it as specifically as possible. Use the **Notes** column to explain ambiguity, missing information, conflicts between documents, or why a term may matter.\n\nIf a key term is not found, do not include a speculative value. Instead, include a row only where the absence itself is material, with \"Not stated\" in the **Value** column. Do not invent facts, citations, clauses, parties, dates, amounts, or obligations.", + "columns_config": null + }, { "user_id": null, "is_system": true, @@ -1616,6 +1697,11 @@ export const SYSTEM_ASSISTANT_WORKFLOWS: { id: string; title: string; skill_md: "title": "Commercial Lease Review", "skill_md": "# Commercial Lease Review\n\n## Instructions\n\nReview the uploaded commercial lease and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High — reinstatement obligation is uncapped\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to the other party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Break Rights | High — Clause [x] imposes vacant possession as a break condition with no cure period. The lessee-specific checklist point indicates the break is fragile and may be lost on a technicality. | For the lessee, remove vacant possession as a break condition or add a cure period and express the condition narrowly. |\n| Drafting Consistency | Medium — Clauses [x] and [y] use inconsistent defined terms for the premises. The general drafting point indicates ambiguity that may affect the represented party's rights. | For the represented party, align defined terms, premises description, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Lessor or Lessee).\n\n| Issue | General | Lessor | Lessee |\n| --- | --- | --- | --- |\n| Parties and Premises | Identify landlord, tenant, guarantor, property, premises, title references, and included or excluded areas. Flag incorrect entities, missing plans, or premises description inconsistent with title. | | |\n| Term | State commencement, expiry, rent commencement, and any conditions precedent. | Flag renewal rights or holding-over provisions that limit the landlord's ability to recover the premises. | Flag uncertain commencement mechanics, no renewal clarity, or landlord-only discretion over start conditions. |\n| Rent and Payments | Confirm rent quantum, payment dates, and deposit mechanics. | Flag unclear payment mechanics, weak late-payment provisions, or rent-free periods exceeding what was agreed. | Flag unclear payment obligations, high default interest, or missing rent-free mechanics. |\n| Rent Review | Identify review dates, mechanism, assumptions, disregards, and dispute process. | Flag review mechanics too favorable to the tenant, downward review provisions, or no upward adjustment. | Flag upward-only review, no cap, aggressive assumptions, or tenant-unfriendly dispute process. |\n| Service Charge and Operating Costs | Identify tenant contribution scope, cap mechanics, audit rights, and reconciliation process. | Flag cost exclusions too broad, caps too low, or audit rights creating undue operational burden. | Flag broad pass-throughs, no cap, capital expenditure exposure, or weak audit rights. |\n| Use | State permitted use, prohibited uses, trading obligations, and change-of-use restrictions. | Flag permitted use too broad or absence of continuous trading obligation. | Flag narrow use restriction, continuous trading obligations, or restrictions inconsistent with operations. |\n| Repairs and Maintenance | Identify landlord and tenant repair obligations, condition standard, and schedule of condition. | Flag limited tenant repairing obligations, repair gaps, or yielding-up conditions below acceptable standard. | Flag full repairing obligation extending to pre-existing defects or structural issues outside tenant control. |\n| Alterations and Fit-Out | Identify consent requirements and reinstatement obligations. | Flag broad alteration rights without landlord consent or no reinstatement obligation. | Flag absolute consent rights, broad reinstatement obligations, or unclear fit-out approval timing. |\n| Assignment, Underletting, and Sharing Occupation | Identify transfer restrictions, consent tests, and group sharing rights. | Flag broad permitted transfer rights allowing assignment to unsuitable tenants without approval. | Flag absolute assignment bans, excessive consent conditions, or no group sharing rights. |\n| Insurance and Damage | Identify who insures, insured risks, rent suspension, and termination rights on damage. | Flag tenant insurance obligations too narrow or absence of rent suspension protection. | Flag no rent suspension on damage, uninsured risk exposure, or no termination right after prolonged damage. |\n| Break Rights | Identify break dates, notice periods, and conditions. | Flag broadly exercisable break conditions allowing tenant exit without adequate penalty. | Flag fragile break conditions, strict vacant possession tests, or excessive preconditions. |\n| Default and Remedies | Identify default events, cure periods, and enforcement rights. | Flag long cure periods, high default thresholds, or limited remedies that delay enforcement. | Flag short cure periods, broad default triggers, or landlord self-help without adequate notice. |\n| Compliance Obligations | Identify which party bears responsibility for regulatory compliance. | Flag compliance gaps exposing the landlord to regulatory liability. | Flag tenant responsibility for landlord works, historic contamination, or compliance beyond the demised premises. |\n| Security Package | Identify security type, quantum, and release conditions. | Flag security inadequate for the tenant's covenant strength or release mechanics too easy to trigger. | Flag excessive security requirements, no step-down on release, or unclear deposit return mechanics. |\n| Rights Reserved and Easements | Identify reserved landlord rights, access arrangements, and tenant rights over common parts. | Flag insufficient reserved rights limiting the landlord's ability to manage the building. | Flag broad landlord entry or disruption rights, or missing rights needed for the tenant's permitted use. |\n| End of Term | Identify yielding-up condition, reinstatement obligations, and dilapidations process. | Flag inadequate reinstatement obligations, unclear yielding-up conditions, or weak dilapidations recovery. | Flag overly broad reinstatement obligations or handback conditions requiring a better standard than at commencement. |\n| Governing Law and Dispute Resolution | State governing law, court forum, expert determination process, and mandatory dispute procedures. Flag ambiguous forum, missing expert determination terms, or inadequate dispute mechanics. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one." }, + { + "id": "builtin-compare-documents", + "title": "Compare Documents", + "skill_md": "# Compare Documents\n\n## Instructions\n\nCompare the uploaded documents for a legal or business reviewer. Focus on the provisions, terms, risks, and commercial points that differ across the documents. The comparison must work for any number of uploaded documents.\n\nProduce the comparison as a Markdown table. The table must have exactly these columns:\n\n- Topic\n- One column for each uploaded document, using the document name or a short readable document label as the column heading\n- Difference\n\nFor example, if three documents are uploaded, the table should use this shape:\n\n| Topic | Document A | Document B | Document C | Difference |\n| --- | --- | --- | --- | --- |\n\nCompare the documents across the most relevant topics, including where available:\n\n- Parties and roles\n- Document date and effective date\n- Term, expiry, renewal, and extension rights\n- Scope of work, services, deliverables, or subject matter\n- Fees, pricing, payment terms, penalties, and currency\n- Conditions precedent, approvals, consents, and notices\n- Representations, warranties, covenants, and restrictions\n- Confidentiality, IP ownership, data protection, and use rights\n- Assignment, transfer, change of control, and subcontracting\n- Termination rights, suspension rights, cure periods, and consequences\n- Liability caps, indemnities, exclusions, insurance, and remedies\n- Governing law, jurisdiction, dispute resolution, and service of process\n\nUse concise entries in the document columns. Under each document column, state the relevant term and include the best available location with citations, such as clause, section, schedule, page, paragraph, or heading. If citations are available, include them inline with the location. If a location is unclear, describe it as specifically as possible.\n\nIn the **Difference** column, explain what changed or diverges, including any negotiation, legal, operational, or commercial significance where useful.\n\nAfter the table, include a short **Key Takeaways** section with no more than five bullets summarising the most important differences and follow-up actions.\n\nDo not invent facts, clauses, parties, dates, amounts, or obligations. If a topic is not addressed in a document, write \"Not stated\" for that document. If the uploaded documents are not comparable, explain why and provide the closest useful comparison." + }, { "id": "builtin-corporate-approvals-review", "title": "Corporate Approvals Review", @@ -1631,6 +1717,11 @@ export const SYSTEM_ASSISTANT_WORKFLOWS: { id: string; title: string; skill_md: "title": "Credit Agreement Review", "skill_md": "# Credit Agreement Review\n\n## Instructions\n\nReview the uploaded credit agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High — covenant headroom is tight\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause or schedule references where available. Do not include issues that are adverse only to the other party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Financial Covenants | High — Clause [x] sets the leverage covenant at [x]x with quarterly testing and no equity cure right. The borrower-specific checklist point indicates limited headroom and no remedy for a technical breach. | For the borrower, widen the covenant threshold, add an equity cure right, and reduce testing frequency to semi-annual. |\n| Drafting Consistency | Medium — Clauses [x] and [y] use inconsistent defined terms for the facility amount. The general drafting point indicates ambiguity that may affect the represented party's rights. | For the represented party, align defined terms, amounts, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Borrower or Lender).\n\n| Issue | General | Borrower | Lender |\n| --- | --- | --- | --- |\n| Parties | Identify all lenders, borrowers, guarantors, agents, and other finance parties with full legal names, roles, and jurisdictions. Flag missing or incorrect entities, unclear agent roles, missing authority confirmations, or transfer mechanics that could result in unknown lenders. | | |\n| Guarantors | Identify guarantors, guarantee scope, limits, and coverage requirements. | Flag uncapped guarantees, missing limits, or upstream guarantee concerns exposing the borrower group. | Flag weak guarantor coverage, missing entities, or guarantees not covering the full facility. |\n| Other Parties | Identify facility agent, security agent, arrangers, issuing banks, and other material parties. | Flag unclear agent roles or mechanics creating additional obligations on the borrower. | Flag inconsistent secured party mechanics or missing parties affecting security validity. |\n| Date of Agreement | State signing date, effective date, and conditions to effectiveness. Flag inconsistent or unclear dates or effectiveness conditions. | | |\n| Facilities | List each facility, type, tranche, availability, and key structural features. | Flag excessive lender discretion, unclear facility purpose, or mismatched terms restricting drawdown. | Flag unclear facility structure or purpose creating enforcement ambiguity. |\n| Amount | State total commitments, currencies, tranche amounts, and accordion or incremental facilities. | Flag unclear currency exposure, uncapped accordion provisions without borrower consent, or inconsistent commitment figures. | Flag uncapped increase mechanics or dilutive accordion provisions not agreed at signing. |\n| Purpose | Summarize permitted use of proceeds and restrictions. | Flag vague purpose wording restricting flexibility or creating sanctions risk. | Flag broad purpose language permitting restricted payment leakage or unapproved use of proceeds. |\n| Interest | Identify reference rate, margin, floors, fallback rates, interest periods, and default interest. | Flag high margins, aggressive floors, benchmark fallback gaps, or high default interest. | Flag unclear fallback rate mechanics or interest calculation ambiguities. |\n| Commitment Fee | State commitment, utilisation, ticking, arrangement, agency, and other fees. | Flag hidden fees, fees payable after cancellation, or unclear calculation basis. | Flag missing fee provisions or fee timing delaying recovery. |\n| Repayment Schedule | Summarize amortisation, scheduled repayments, bullet payments, and cash sweep. | Flag aggressive amortisation, unclear prepayment application, or cash sweep uncertainty. | Flag bullet repayments without adequate covenant or security protection. |\n| Maturity | State final maturity date and any extension options. | Flag short maturity, lender-only extension discretion, or mismatch with the business plan. | Flag unclear extension conditions or borrower-controlled extension mechanics. |\n| Security | Identify security package, assets, entities, perfection steps, and post-closing obligations. | Flag all-asset security beyond deal scope, onerous post-closing steps, or missing release mechanics on disposal. | Flag gaps in the security package, missing perfection steps, or inadequate post-closing obligations. |\n| Guarantees | Summarize guarantee obligations, guarantors, limitations, and release triggers. | Flag broad all-monies language, unlimited guarantees, or no release mechanics after repayment or disposal. | Flag guarantee gaps, weak limitations, or no coverage test requirements. |\n| Financial Covenants | Identify covenant metrics, thresholds, testing frequency, cure rights, and reporting. | Flag tight headroom, frequent testing, no equity cure, or ambiguous EBITDA adjustments. | Flag loose covenant thresholds or wide EBITDA adjustments obscuring true financial performance. |\n| Events of Default | Summarize default triggers, grace periods, materiality thresholds, cross-default, and MAE. | Flag low materiality thresholds, no cure periods, broad cross-default, or subjective MAE defaults. | Flag weak default triggers, long cure periods, or materiality thresholds delaying enforcement. |\n| Assignment | Summarize lender transfer rights, borrower consent mechanics, and disqualified institutions. | Flag free transfers to competitors, distressed investors, or lenders on a borrower blacklist. | Flag consent mechanics giving the borrower excessive veto over lender transfers. |\n| Change of Control | Identify triggers and resulting prepayment, cancellation, or default consequences. | Flag broad triggers, no cure period, or definitions catching ordinary-course ownership changes. | Flag narrow change of control definitions missing material ownership shifts. |\n| Prepayment Fee | Identify make-whole, soft-call, premium periods, and exceptions. | Flag excessive fees, long premium periods, or unclear exceptions for mandatory prepayments. | Flag exceptions allowing free prepayment during premium periods. |\n| Governing Law and Dispute Resolution | State governing law, jurisdiction, forum, and service of process. Flag unclear or ambiguous governing law, jurisdiction, or service mechanics. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one." }, + { + "id": "builtin-draft-from-template", + "title": "Draft from Template", + "skill_md": "# Draft from Template\n\n## Instructions\n\nUse the uploaded template as the drafting structure for a new document. Treat the template as the primary source for document style, section order, clause structure, defined-term conventions, placeholders, and formatting cues.\n\nBefore drafting, identify whether the user has provided enough instructions and source information to complete the draft. If key information is missing, ask concise follow-up questions instead of inventing facts.\n\nWhen drafting:\n\n- Preserve the template structure unless the user asks for changes.\n- Replace placeholders, bracketed fields, blanks, and example text with the user-provided facts where available.\n- Keep definitions, cross-references, numbering, party names, dates, schedules, and exhibits internally consistent.\n- Do not remove protective clauses, boilerplate, signatures, schedules, or annexes unless the user asks you to do so.\n- If the user provides supporting documents, use them only for facts and context that are relevant to completing the template.\n- Flag any assumptions, missing inputs, unresolved placeholders, or provisions that require legal or commercial confirmation.\n\nIf the user asks for a downloadable draft, use the document generation tools available to produce the drafted document. Otherwise, provide the drafted text or the most relevant sections inline, matching the template as closely as possible.\n\nDo not invent parties, dates, amounts, governing law, notice details, commercial terms, or factual background. If information is unavailable, leave a clear placeholder and list it in an **Open Points** section." + }, { "id": "builtin-draft-issues-list", "title": "Draft Issues List", @@ -1641,6 +1732,11 @@ export const SYSTEM_ASSISTANT_WORKFLOWS: { id: string; title: string; skill_md: "title": "Employment Agreement Review", "skill_md": "# Employment Agreement Review\n\n## Instructions\n\nReview the uploaded employment agreement and produce a comprehensive table-based legal review from the perspective of the party represented by the user/client. If the user has not already identified which party they represent, ask them to clarify that party before producing the review.\n\nOnce the represented party is clear, provide exactly one Markdown result table. Use one row for each material issue found in the agreement, guided by the review checklist below, and add a final row called **Overall Risk Rating**. The result table must have exactly these columns:\n\n- Issue\n- Summary\n- Recommended Change\n\nUse these risk ratings inside the **Summary** column: Low means standard or minimal concern; Medium means a manageable negotiation concern; High means a material legal, commercial, operational, or enforceability concern requiring negotiation; Critical means a severe issue that may block signing unless resolved. Start each summary with the risk rating, e.g. \"High - restrictive covenant scope is excessive\".\n\nThe **Summary** column should include only the relevant points from the **General** checklist column and the party-specific checklist column for the represented party, together with clause references where available. Do not include issues that are adverse only to another party unless they also create risk for the represented party. The **Recommended Change** column must be drafted from the represented party's perspective. Also flag general drafting errors that may affect the represented party, including inconsistent defined terms, inconsistent entity names, cross-reference errors, numbering issues, duplicated provisions, missing schedules, and internal inconsistencies. Keep the response concise and avoid long prose outside the table.\n\n## Result Table Format\n\nUse this result table structure. Include annotations such as [1] and [2] in the **Summary** and **Recommended Change** columns to reference the relevant sections of the document. The example rows are illustrative only; tailor the actual rows to the uploaded agreement and represented party.\n\n| Issue | Summary | Recommended Change |\n| --- | --- | --- |\n| Restrictive Covenants | High - Clause [x] imposes a long non-compete with broad geographic scope. The general covenant position and the employee-specific checklist point indicate enforceability and mobility risk for the employee. | For the employee, reduce the duration, territory, and restricted activities to what is necessary for legitimate business protection. |\n| Drafting Consistency | Medium - Clauses [x] and [y] use inconsistent defined terms or party names. The general drafting point indicates ambiguity that may affect the represented party's rights or obligations. | For the represented party, align the defined terms, party names, numbering, and cross-references before signing. |\n\n## Review Checklist\n\nUse this checklist as guidance for what to review and flag. It is not the result-table template and should not be reproduced verbatim. For each checklist issue, consider both the **General** column and the party-specific column for the represented party (Employer or Employee).\n\n| Issue | General | Employer | Employee |\n| --- | --- | --- | --- |\n| Parties and Role | Identify employer and employee with correct legal names. Confirm job title, reporting line, work location, and employment type. Flag incorrect entities, unclear role scope, or terms creating unintended employment relationships. | | |\n| Start Date and Term | State commencement date, probation period, fixed term, renewal mechanics, and conditions precedent. | Flag unclear commencement mechanics or terms that limit employer flexibility during the probation period. | Flag long probation periods, unclear renewal mechanics, or employer-only extension rights. |\n| Duties | Summarize duties, working hours, exclusivity, travel requirements, and flexibility clauses. | Flag overly narrow duties that restrict operational flexibility or the employer's ability to change the role. | Flag open-ended duties, excessive hours, broad travel requirements, or unilateral employer change rights. |\n| Compensation | Identify salary, payment frequency, reviews, bonus, commission, allowances, and discretion. | Flag discretionary compensation arrangements that may create enforceable entitlement expectations. | Flag fully discretionary compensation, unclear bonus criteria, or missing payment timing. |\n| Benefits | Summarize pension, insurance, leave, expenses, equity incentives, and employer discretion. | Flag benefit commitments that may be difficult to modify or withdraw without breach. | Flag benefits that can be withdrawn without notice or that conflict with the offer letter. |\n| Holiday and Leave | State annual leave, sickness absence, statutory leave, notice requirements, and carry-over rules. | Flag carry-over obligations or leave accrual that creates significant financial liability. | Flag entitlements below statutory minimums, unclear carry-over rules, or inadequate sick pay. |\n| Policies and Handbook | Identify incorporated policies, contractual status, and amendment rights. | Flag policies incorporated as contractual that cannot be changed unilaterally without employee consent. | Flag policies incorporated as contractual while the employer retains unilateral amendment rights. |\n| Confidentiality | Summarize confidentiality obligations during and after employment. | Flag confidentiality obligations too narrow to protect business-critical information. | Flag overbroad confidential information definitions or indefinite restrictions beyond legitimate business interests. |\n| Intellectual Property | Identify ownership, assignment, inventions, works, moral rights waivers, and assistance obligations. | Flag gaps in IP assignment covering inventions or works made during employment or using company resources. | Flag assignment of unrelated personal inventions, works outside employment scope, or unpaid post-employment assistance obligations. |\n| Data Protection and Monitoring | Summarize monitoring, privacy, processing, and consent provisions. | Flag monitoring arrangements that may expose the employer to data protection liability. | Flag intrusive monitoring, blanket consent provisions, or missing privacy notice references. |\n| Conflicts and Outside Activities | Identify restrictions on outside work, directorships, investments, conflicts, and disclosure duties. | Flag insufficient restrictions on outside activities or conflicts that could harm business interests. | Flag broad bans on passive investments, restrictions on unrelated outside work, or disclosure obligations for non-conflicting activities. |\n| Termination | State notice periods, payment in lieu, garden leave, summary dismissal, severance, and survival terms. | Flag notice periods or severance obligations that unduly limit employer termination flexibility. | Flag one-sided notice rights, broad summary dismissal triggers, or unclear payment in lieu treatment. |\n| Restrictive Covenants | Summarize non-compete, non-solicit, non-dealing, non-poach, and confidentiality covenants. | Flag covenants too narrow in scope, too short in duration, or likely to be unenforceable. | Flag excessive duration, broad geographic scope, wide covered customers, or covenants that may be unenforceable restraints. |\n| Return of Property | Identify obligations to return devices, documents, data, confidential information, and property. | Flag unclear scope of return obligations or no mechanism to enforce retrieval of business property. | Flag no carve-out for personal materials or obligations that require returning statutory record retention copies. |\n| Governing Law and Dispute Resolution | State governing law, courts, tribunal forum, arbitration, and mandatory procedures. Flag unfamiliar law, unclear mandatory procedures, or arbitration clauses that may limit statutory rights. | | |\n\nDeliver the review inline in your chat response. Do not generate a downloadable Word document unless the user explicitly asks for one." }, + { + "id": "builtin-extract-key-terms", + "title": "Extract Key Terms", + "skill_md": "# Extract Key Terms\n\n## Instructions\n\nExtract the key legal, commercial, and operational terms from the uploaded documents. Present the result as a concise Markdown table.\n\nThe table must have exactly these columns:\n\n- Term\n- Value\n- Location\n- Notes\n\nExtract the terms that are most useful for legal review, including where available:\n\n- Parties and roles\n- Document date and effective date\n- Term, expiry, renewal, and extension rights\n- Scope of work, services, deliverables, or subject matter\n- Fees, pricing, payment terms, interest, penalties, and currency\n- Conditions precedent, approvals, consents, and notices\n- Representations, warranties, covenants, and restrictions\n- Confidentiality, IP ownership, data protection, and use rights\n- Assignment, transfer, change of control, and subcontracting\n- Termination rights, suspension rights, cure periods, and consequences\n- Liability caps, indemnities, exclusions, insurance, and remedies\n- Governing law, jurisdiction, dispute resolution, and service of process\n\nUse the **Location** column for the best available clause, section, schedule, page, or paragraph reference. If a location is unclear, describe it as specifically as possible. Use the **Notes** column to explain ambiguity, missing information, conflicts between documents, or why a term may matter.\n\nIf a key term is not found, do not include a speculative value. Instead, include a row only where the absence itself is material, with \"Not stated\" in the **Value** column. Do not invent facts, citations, clauses, parties, dates, amounts, or obligations." + }, { "id": "builtin-guarantee-agreement-review", "title": "Guarantee Agreement Review", diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index dd7c0de..22ecd22 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -65,6 +65,7 @@ documentsRouter.get("/", requireAuth, async (req, res) => { .select("*") .eq("user_id", userId) .is("project_id", null) + .or("library_kind.eq.file,library_kind.is.null") .order("created_at", { ascending: false }); if (error) return void res.status(500).json({ detail: error.message }); const docs = (data ?? []) as unknown as { @@ -84,7 +85,9 @@ documentsRouter.post( async (req, res) => { const userId = res.locals.userId as string; const db = createServerSupabase(); - await handleDocumentUpload(req, res, userId, null, db); + await handleDocumentUpload(req, res, userId, null, db, { + libraryKind: "file", + }); }, ); @@ -426,9 +429,13 @@ documentsRouter.post( if (!sourceAccess.ok) return void res.status(404).json({ detail: "Source document not found" }); const willDeleteSource = - sourceDoc.project_id && - targetDoc.project_id && - sourceDoc.project_id === targetDoc.project_id; + (sourceDoc.project_id && + targetDoc.project_id && + sourceDoc.project_id === targetDoc.project_id) || + (!sourceDoc.project_id && + !targetDoc.project_id && + sourceDoc.user_id === userId && + targetDoc.user_id === userId); if (willDeleteSource && !sourceAccess.isOwner) { return void res.status(403).json({ detail: "Only the source document owner can move it into a version.", @@ -1283,12 +1290,16 @@ documentsRouter.post( (req, res) => void handleEditResolution(req, res, "reject"), ); -async function handleDocumentUpload( +export async function handleDocumentUpload( req: import("express").Request, res: import("express").Response, userId: string, projectId: string | null, db: ReturnType, + options: { + libraryKind?: "file" | "template"; + libraryFolderId?: string | null; + } = {}, ) { const file = req.file; if (!file) return void res.status(400).json({ detail: "file is required" }); @@ -1311,6 +1322,8 @@ async function handleDocumentUpload( project_id: projectId, user_id: userId, status: "processing", + library_kind: options.libraryKind ?? "file", + library_folder_id: options.libraryFolderId ?? null, }) .select("*") .single(); @@ -1417,6 +1430,8 @@ async function handleDocumentUpload( filename, storage_path: key, pdf_storage_path: pdfStoragePath, + folder_id: + (updated.library_folder_id as string | null | undefined) ?? null, file_type: suffix, size_bytes: content.byteLength, page_count: pageCount, diff --git a/backend/src/routes/library.ts b/backend/src/routes/library.ts new file mode 100644 index 0000000..d46bf71 --- /dev/null +++ b/backend/src/routes/library.ts @@ -0,0 +1,414 @@ +import { Router } from "express"; +import { requireAuth } from "../middleware/auth"; +import { createServerSupabase } from "../lib/supabase"; +import { deleteFile } from "../lib/storage"; +import { + attachActiveVersionPaths, + attachLatestVersionNumbers, +} from "../lib/documentVersions"; +import { singleFileUpload } from "../lib/upload"; +import { handleDocumentUpload } from "./documents"; + +export const libraryRouter = Router(); + +type LibraryKind = "file" | "template"; + +function normalizeLibraryKind(value: unknown): LibraryKind | null { + if (value === "file" || value === "files") return "file"; + if (value === "template" || value === "templates") return "template"; + return null; +} + +function normalizeDocumentFilename(nextName: unknown, currentName: string) { + if (typeof nextName !== "string") return null; + const trimmed = nextName.trim().slice(0, 200); + if (!trimmed) return null; + if (/\.[a-z0-9]{1,6}$/i.test(trimmed)) return trimmed; + const ext = currentName.match(/\.[a-z0-9]{1,6}$/i)?.[0] ?? ""; + return `${trimmed}${ext}`; +} + +function mapLibraryDocument>(doc: T) { + return { + ...doc, + folder_id: (doc.library_folder_id as string | null | undefined) ?? null, + }; +} + +async function loadLibraryFolder( + db: ReturnType, + userId: string, + kind: LibraryKind, + folderId: string, +): Promise<{ id: string; parent_folder_id: string | null } | null> { + const { data } = await db + .from("library_folders") + .select("id, parent_folder_id") + .eq("id", folderId) + .eq("user_id", userId) + .eq("library_kind", kind) + .maybeSingle(); + return (data as { id: string; parent_folder_id: string | null } | null) ?? null; +} + +async function deleteLibraryDocumentsAndVersionFiles( + db: ReturnType, + userId: string, + kind: LibraryKind, + documentIds: string[], +) { + if (documentIds.length === 0) return null; + const { data: versions, error: versionsError } = await db + .from("document_versions") + .select("storage_path, pdf_storage_path") + .in("document_id", documentIds); + if (versionsError) return versionsError; + + const paths = new Set(); + for (const version of versions ?? []) { + if (typeof version.storage_path === "string" && version.storage_path) { + paths.add(version.storage_path); + } + if ( + typeof version.pdf_storage_path === "string" && + version.pdf_storage_path + ) { + paths.add(version.pdf_storage_path); + } + } + await Promise.all([...paths].map((path) => deleteFile(path).catch(() => {}))); + + let deleteQuery = db + .from("documents") + .delete() + .eq("user_id", userId) + .is("project_id", null); + deleteQuery = + kind === "file" + ? deleteQuery.or("library_kind.eq.file,library_kind.is.null") + : deleteQuery.eq("library_kind", kind); + const { error } = await deleteQuery.in("id", documentIds); + return error ?? null; +} + +// GET /library/:kind +libraryRouter.get("/:kind", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + + const db = createServerSupabase(); + let documentsQuery = db + .from("documents") + .select("*") + .eq("user_id", userId) + .is("project_id", null); + documentsQuery = + kind === "file" + ? documentsQuery.or("library_kind.eq.file,library_kind.is.null") + : documentsQuery.eq("library_kind", kind); + const [{ data: docs, error: docsError }, { data: folders, error: foldersError }] = + await Promise.all([ + documentsQuery.order("created_at", { ascending: true }), + db + .from("library_folders") + .select("*") + .eq("user_id", userId) + .eq("library_kind", kind) + .order("created_at", { ascending: true }), + ]); + if (docsError) return void res.status(500).json({ detail: docsError.message }); + if (foldersError) + return void res.status(500).json({ detail: foldersError.message }); + + const docsTyped = (docs ?? []).map(mapLibraryDocument) as { + id: string; + current_version_id?: string | null; + }[]; + await attachLatestVersionNumbers(db, docsTyped); + await attachActiveVersionPaths(db, docsTyped); + res.json({ documents: docsTyped, folders: folders ?? [] }); +}); + +// POST /library/:kind/documents +libraryRouter.post( + "/:kind/documents", + requireAuth, + singleFileUpload("file"), + async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + const db = createServerSupabase(); + await handleDocumentUpload(req, res, userId, null, db, { + libraryKind: kind, + }); + }, +); + +// POST /library/:kind/folders +libraryRouter.post("/:kind/folders", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + + const { name, parent_folder_id } = req.body as { + name?: string; + parent_folder_id?: string | null; + }; + if (!name?.trim()) + return void res.status(400).json({ detail: "name is required" }); + + const db = createServerSupabase(); + if (parent_folder_id) { + const parent = await loadLibraryFolder(db, userId, kind, parent_folder_id); + if (!parent) + return void res.status(404).json({ detail: "Parent folder not found" }); + } + + const { data, error } = await db + .from("library_folders") + .insert({ + user_id: userId, + library_kind: kind, + name: name.trim(), + parent_folder_id: parent_folder_id ?? null, + }) + .select("*") + .single(); + if (error) return void res.status(500).json({ detail: error.message }); + res.status(201).json(data); +}); + +// PATCH /library/:kind/folders/:folderId +libraryRouter.patch("/:kind/folders/:folderId", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + + const { folderId } = req.params; + const body = req.body as { name?: string; parent_folder_id?: string | null }; + const db = createServerSupabase(); + const folder = await loadLibraryFolder(db, userId, kind, folderId); + if (!folder) return void res.status(404).json({ detail: "Folder not found" }); + + const updates: Record = { + updated_at: new Date().toISOString(), + }; + if (body.name != null) { + const trimmed = body.name.trim(); + if (!trimmed) + return void res.status(400).json({ detail: "name is required" }); + updates.name = trimmed; + } + if ("parent_folder_id" in body) { + if (body.parent_folder_id) { + let cur: string | null = body.parent_folder_id; + while (cur) { + if (cur === folderId) { + return void res.status(400).json({ + detail: "Cannot move a folder into itself or a descendant", + }); + } + const parent = await loadLibraryFolder(db, userId, kind, cur); + if (!parent) + return void res.status(404).json({ detail: "Parent folder not found" }); + cur = parent.parent_folder_id ?? null; + } + } + updates.parent_folder_id = body.parent_folder_id ?? null; + } + + const { data, error } = await db + .from("library_folders") + .update(updates) + .eq("id", folderId) + .eq("user_id", userId) + .eq("library_kind", kind) + .select("*") + .single(); + if (error || !data) + return void res.status(404).json({ detail: "Folder not found" }); + res.json(data); +}); + +// DELETE /library/:kind/folders/:folderId +libraryRouter.delete("/:kind/folders/:folderId", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + + const { folderId } = req.params; + const db = createServerSupabase(); + const { data: allFolders, error: foldersError } = await db + .from("library_folders") + .select("id, parent_folder_id") + .eq("user_id", userId) + .eq("library_kind", kind); + if (foldersError) + return void res.status(500).json({ detail: foldersError.message }); + if (!(allFolders ?? []).some((folder) => folder.id === folderId)) { + return void res.status(404).json({ detail: "Folder not found" }); + } + + const childrenByParent = new Map(); + for (const folder of allFolders ?? []) { + const parentId = folder.parent_folder_id as string | null; + if (!parentId) continue; + const children = childrenByParent.get(parentId) ?? []; + children.push(folder.id as string); + childrenByParent.set(parentId, children); + } + + const folderIds = new Set(); + const stack = [folderId]; + while (stack.length > 0) { + const id = stack.pop()!; + if (folderIds.has(id)) continue; + folderIds.add(id); + stack.push(...(childrenByParent.get(id) ?? [])); + } + + let documentsInFolderQuery = db + .from("documents") + .select("id") + .eq("user_id", userId) + .is("project_id", null); + documentsInFolderQuery = + kind === "file" + ? documentsInFolderQuery.or("library_kind.eq.file,library_kind.is.null") + : documentsInFolderQuery.eq("library_kind", kind); + const { data: docs, error: docsError } = await documentsInFolderQuery.in( + "library_folder_id", + [...folderIds], + ); + if (docsError) return void res.status(500).json({ detail: docsError.message }); + + const docIds = (docs ?? []).map((doc) => doc.id as string); + const deleteDocsError = await deleteLibraryDocumentsAndVersionFiles( + db, + userId, + kind, + docIds, + ); + if (deleteDocsError) + return void res.status(500).json({ detail: deleteDocsError.message }); + + const { error } = await db + .from("library_folders") + .delete() + .eq("id", folderId) + .eq("user_id", userId) + .eq("library_kind", kind); + if (error) return void res.status(500).json({ detail: error.message }); + res.status(204).send(); +}); + +// PATCH /library/:kind/documents/:documentId/folder +libraryRouter.patch( + "/:kind/documents/:documentId/folder", + requireAuth, + async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + + const { documentId } = req.params; + const { folder_id } = req.body as { folder_id: string | null }; + const db = createServerSupabase(); + + if (folder_id) { + const folder = await loadLibraryFolder(db, userId, kind, folder_id); + if (!folder) + return void res.status(404).json({ detail: "Folder not found" }); + } + + let moveQuery = db + .from("documents") + .update({ + library_folder_id: folder_id ?? null, + updated_at: new Date().toISOString(), + }) + .eq("id", documentId) + .eq("user_id", userId) + .is("project_id", null); + moveQuery = + kind === "file" + ? moveQuery.or("library_kind.eq.file,library_kind.is.null") + : moveQuery.eq("library_kind", kind); + const { data, error } = await moveQuery + .select("*") + .single(); + if (error || !data) + return void res.status(404).json({ detail: "Document not found" }); + res.json(mapLibraryDocument(data)); + }, +); + +// PATCH /library/:kind/documents/:documentId +libraryRouter.patch( + "/:kind/documents/:documentId", + requireAuth, + async (req, res) => { + const userId = res.locals.userId as string; + const kind = normalizeLibraryKind(req.params.kind); + if (!kind) return void res.status(404).json({ detail: "Library not found" }); + + const { documentId } = req.params; + const db = createServerSupabase(); + let docQuery = db + .from("documents") + .select("id, current_version_id") + .eq("id", documentId) + .eq("user_id", userId) + .is("project_id", null); + docQuery = + kind === "file" + ? docQuery.or("library_kind.eq.file,library_kind.is.null") + : docQuery.eq("library_kind", kind); + const { data: doc } = await docQuery.single(); + if (!doc) return void res.status(404).json({ detail: "Document not found" }); + + const active = doc.current_version_id + ? await db + .from("document_versions") + .select("filename") + .eq("id", doc.current_version_id) + .eq("document_id", documentId) + .single() + : null; + const currentName = + typeof active?.data?.filename === "string" && active.data.filename.trim() + ? active.data.filename.trim() + : "Untitled document"; + const filename = normalizeDocumentFilename(req.body?.filename, currentName); + if (!filename) + return void res.status(400).json({ detail: "filename is required" }); + + let updateQuery = db + .from("documents") + .update({ updated_at: new Date().toISOString() }) + .eq("id", documentId) + .eq("user_id", userId) + .is("project_id", null); + updateQuery = + kind === "file" + ? updateQuery.or("library_kind.eq.file,library_kind.is.null") + : updateQuery.eq("library_kind", kind); + const { data: updated, error } = await updateQuery + .select("*") + .single(); + if (error || !updated) + return void res.status(404).json({ detail: "Document not found" }); + + if (doc.current_version_id) { + await db + .from("document_versions") + .update({ filename }) + .eq("id", doc.current_version_id) + .eq("document_id", documentId); + } + + res.json(mapLibraryDocument({ ...updated, filename })); + }, +); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 76b7181..cdf03b4 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -453,7 +453,11 @@ projectsRouter.post( // Standalone → assign project_id const { data: updated, error } = await db .from("documents") - .update({ project_id: projectId, updated_at: new Date().toISOString() }) + .update({ + project_id: projectId, + library_folder_id: null, + updated_at: new Date().toISOString(), + }) .eq("id", documentId) .select("*") .single(); diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index de9af9b..4c5c5c2 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -997,6 +997,29 @@ tabularRouter.delete( }, ); +// PATCH /tabular-review/:reviewId/chats/:chatId — rename a chat +tabularRouter.patch( + "/:reviewId/chats/:chatId", + requireAuth, + async (req, res) => { + const userId = res.locals.userId as string; + const { chatId } = req.params; + const title = + typeof req.body?.title === "string" ? req.body.title.trim() : ""; + if (!title) + return void res.status(400).json({ detail: "Title is required" }); + const db = createServerSupabase(); + // Owner-only rename — mirrors the delete rule above. + const { error } = await db + .from("tabular_review_chats") + .update({ title: title.slice(0, 200) }) + .eq("id", chatId) + .eq("user_id", userId); + if (error) return void res.status(500).json({ detail: error.message }); + res.status(204).send(); + }, +); + // GET /tabular-review/:reviewId/chats/:chatId/messages — messages for a single chat tabularRouter.get( "/:reviewId/chats/:chatId/messages", diff --git a/frontend/public/icons/app-sidebar/chat.svg b/frontend/public/icons/app-sidebar/chat.svg new file mode 100644 index 0000000..8ad913e --- /dev/null +++ b/frontend/public/icons/app-sidebar/chat.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/app-sidebar/folder-closed.svg b/frontend/public/icons/app-sidebar/folder-closed.svg new file mode 100644 index 0000000..da3b9c2 --- /dev/null +++ b/frontend/public/icons/app-sidebar/folder-closed.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/app-sidebar/folder-open.svg b/frontend/public/icons/app-sidebar/folder-open.svg new file mode 100644 index 0000000..6c2f8e6 --- /dev/null +++ b/frontend/public/icons/app-sidebar/folder-open.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/app-sidebar/library.svg b/frontend/public/icons/app-sidebar/library.svg new file mode 100644 index 0000000..e68ea92 --- /dev/null +++ b/frontend/public/icons/app-sidebar/library.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/app-sidebar/project-closed.svg b/frontend/public/icons/app-sidebar/project-closed.svg new file mode 100644 index 0000000..62089f3 --- /dev/null +++ b/frontend/public/icons/app-sidebar/project-closed.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/app-sidebar/project-opened.svg b/frontend/public/icons/app-sidebar/project-opened.svg new file mode 100644 index 0000000..876e491 --- /dev/null +++ b/frontend/public/icons/app-sidebar/project-opened.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/app-sidebar/quick-actions.svg b/frontend/public/icons/app-sidebar/quick-actions.svg new file mode 100644 index 0000000..5c21d0e --- /dev/null +++ b/frontend/public/icons/app-sidebar/quick-actions.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/app-sidebar/tabular-review.svg b/frontend/public/icons/app-sidebar/tabular-review.svg new file mode 100644 index 0000000..dcbb359 --- /dev/null +++ b/frontend/public/icons/app-sidebar/tabular-review.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/app-sidebar/workflow.svg b/frontend/public/icons/app-sidebar/workflow.svg new file mode 100644 index 0000000..74c59d8 --- /dev/null +++ b/frontend/public/icons/app-sidebar/workflow.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/file-types/.gitkeep b/frontend/public/icons/file-types/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/public/icons/file-types/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/public/icons/file-types/chat.svg b/frontend/public/icons/file-types/chat.svg new file mode 100644 index 0000000..553e50d --- /dev/null +++ b/frontend/public/icons/file-types/chat.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/file-types/excel.svg b/frontend/public/icons/file-types/excel.svg new file mode 100644 index 0000000..51f4e8a --- /dev/null +++ b/frontend/public/icons/file-types/excel.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/file-types/pdf.svg b/frontend/public/icons/file-types/pdf.svg new file mode 100644 index 0000000..0375457 --- /dev/null +++ b/frontend/public/icons/file-types/pdf.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/file-types/ppt.svg b/frontend/public/icons/file-types/ppt.svg new file mode 100644 index 0000000..eb8294f --- /dev/null +++ b/frontend/public/icons/file-types/ppt.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/file-types/word.svg b/frontend/public/icons/file-types/word.svg new file mode 100644 index 0000000..b912405 --- /dev/null +++ b/frontend/public/icons/file-types/word.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/workflow.svg b/frontend/public/workflow.svg new file mode 100644 index 0000000..0b187f7 --- /dev/null +++ b/frontend/public/workflow.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/app/(pages)/account/features/page.tsx b/frontend/src/app/(pages)/account/features/page.tsx index 4ecfa4e..1c0d68a 100644 --- a/frontend/src/app/(pages)/account/features/page.tsx +++ b/frontend/src/app/(pages)/account/features/page.tsx @@ -3,10 +3,14 @@ import { useEffect, useRef, useState } from "react"; import { Check } from "lucide-react"; import { useUserProfile } from "@/app/contexts/UserProfileContext"; +import { useQuickActionsPreference } from "@/app/components/assistant/quickActionsPreferences"; import { AccountSection } from "../AccountSection"; +import { AccountToggle } from "../AccountToggle"; export default function FeaturesPage() { const { profile, updateLegalResearchUs } = useUserProfile(); + const { visibleActions, showAllQuickActions, hideAllQuickActions } = + useQuickActionsPreference(); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [saveError, setSaveError] = useState(null); @@ -26,6 +30,7 @@ export default function FeaturesPage() { const hasChanges = draftLegalResearchUs !== null && draftLegalResearchUs !== persistedLegalResearchUs; + const quickActionsEnabled = Object.values(visibleActions).some(Boolean); const handleUpdateLegalResearch = async () => { if (saving) return; @@ -46,6 +51,38 @@ export default function FeaturesPage() { return (
+
+
+

+ Assistant +

+
+ +
+
+

+ Quick actions +

+

+ Show the quick actions row on the assistant + start screen. +

+
+ { + if (checked) { + showAllQuickActions(); + } else { + hideAllQuickActions(); + } + }} + /> +
+
+
+

diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 64e6a57..6133c6e 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -4,12 +4,14 @@ import { useEffect, useRef, useState } from "react"; import { AlertCircle, Check, ChevronDown, Loader2 } from "lucide-react"; import { DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/app/components/ui/dropdown-menu"; +import { + LiquidDropdownContent, + LiquidDropdownItem, +} from "@/app/components/ui/liquid-dropdown"; import { useUserProfile } from "@/app/contexts/UserProfileContext"; import type { ApiKeyState } from "@/app/lib/mikeApi"; import { @@ -178,7 +180,7 @@ function ModelPreferenceDropdown({ )} - onChange(m.id)} @@ -219,13 +221,13 @@ function ModelPreferenceDropdown({ {m.id === value && available && ( )} - + ); })}

); })} - + ); } diff --git a/frontend/src/app/(pages)/layout.tsx b/frontend/src/app/(pages)/layout.tsx index 059af46..107c514 100644 --- a/frontend/src/app/(pages)/layout.tsx +++ b/frontend/src/app/(pages)/layout.tsx @@ -102,7 +102,7 @@ export default function MikeLayout({ }, }} > -
+
{activeTab ? ( - isDocxTab(activeTab.filename) ? ( + isDocxFilename(activeTab.filename) ? ( + setProject((prev) => + prev + ? { + ...prev, + documents: [ + ...(prev.documents ?? []), + ...documents, + ], + } + : prev, + ) + } projectName={project?.name} projectCmNumber={project?.cm_number} /> diff --git a/frontend/src/app/(pages)/projects/[id]/assistant/page.tsx b/frontend/src/app/(pages)/projects/[id]/assistant/page.tsx index 4ca0401..18cb62d 100644 --- a/frontend/src/app/(pages)/projects/[id]/assistant/page.tsx +++ b/frontend/src/app/(pages)/projects/[id]/assistant/page.tsx @@ -1,7 +1,7 @@ "use client"; import { use, useCallback, useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { ChevronDown } from "lucide-react"; import { deleteChat, renameChat } from "@/app/lib/mikeApi"; import { ProjectAssistantTable } from "@/app/components/projects/ProjectAssistantTable"; @@ -11,6 +11,7 @@ import { } from "@/app/components/projects/ProjectWorkspace"; import type { Chat } from "@/app/components/shared/types"; import { useAuth } from "@/app/contexts/AuthContext"; +import { TabPillButton } from "@/app/components/ui/tab-pill-button"; interface Props { params: Promise<{ id: string }>; @@ -31,13 +32,12 @@ function SelectedChatActions({ return (
- + {open && (
+ {open && (
+ {actionsOpen && ( -
+ -
+ )}
) : undefined; @@ -273,7 +398,10 @@ export default function TabularReviewsPage() { { + setActiveScope(scope); + clearSelection(); + }} actions={toolbarActions} /> @@ -282,8 +410,8 @@ export default function TabularReviewsPage() { header={ - {loading ? ( - + {effectiveLoading ? ( + ) : ( )} - Name + Name + {!loading && nameFilterButton} - Columns +
+ Columns + {!loading && columnsFilterButton} +
+
+ +
+ Documents + {!loading && documentsFilterButton} +
- Documents
Project - {projectFilterButton} + {!loading && projectFilterButton} +
+
+ +
+ Created + {!loading && createdFilterButton}
- Created
} > - - {loading ? ( + {effectiveLoading ? ( {[1, 2, 3].map((i) => ( - + @@ -347,7 +488,7 @@ export default function TabularReviewsPage() { {activeScope === "all" && !projectFilter ? ( <> - +

Tabular Reviews

@@ -355,13 +496,16 @@ export default function TabularReviewsPage() { Extract data from documents into tables using AI.

- + + Create + ) : (

@@ -372,18 +516,19 @@ export default function TabularReviewsPage() { ) : ( {filtered.map((review) => { - const project = projects.find( - (p) => p.id === review.project_id, - ); + const projectName = review.project_id + ? projectNameById.get(review.project_id) + : null; const rowBg = selectedIds.includes(review.id) ? "bg-gray-50" : TABLE_STICKY_CELL_BG; return ( ( + rightClickDropdown={(close, menuProps) => ( { requestReviewDetails(review); }} @@ -436,8 +581,8 @@ export default function TabularReviewsPage() { {review.document_count ?? 0} - {project ? ( - project.name + {projectName ? ( + projectName ) : ( — diff --git a/frontend/src/app/components/assistant/AddDocButton.tsx b/frontend/src/app/components/assistant/AddDocButton.tsx index ee80089..cfe69a2 100644 --- a/frontend/src/app/components/assistant/AddDocButton.tsx +++ b/frontend/src/app/components/assistant/AddDocButton.tsx @@ -19,8 +19,8 @@ export function AddDocButton({ onClick={onBrowseAll} className={`flex items-center gap-1 px-2 h-8 rounded-lg text-sm transition-colors cursor-pointer ${ selectedDocIds.length > 0 - ? "text-black hover:bg-gray-100" - : "text-gray-400 hover:text-gray-700 hover:bg-gray-100" + ? "text-gray-700 hover:text-gray-900" + : "text-gray-400 hover:text-gray-700" }`} title="Add documents" aria-label="Add documents" diff --git a/frontend/src/app/components/assistant/AskInputPopup.tsx b/frontend/src/app/components/assistant/AskInputPopup.tsx index 35a9d33..05a20b2 100644 --- a/frontend/src/app/components/assistant/AskInputPopup.tsx +++ b/frontend/src/app/components/assistant/AskInputPopup.tsx @@ -1,27 +1,12 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { - AlertCircle, - Check, - CornerDownLeft, - Loader2, - Upload, - X, -} from "lucide-react"; -import { cn } from "@/app/lib/utils"; +import { useCallback, useEffect, useState } from "react"; +import { Check, X } from "lucide-react"; +import { PillButton } from "@/app/components/ui/pill-button"; +import { TabPillButton } from "@/app/components/ui/tab-pill-button"; import type { AssistantEvent, Document } from "../shared/types"; import { FileTypeIcon } from "../shared/FileTypeIcon"; -import { - AddDocumentsModal, - invalidateDirectoryCache, -} from "../modals/AddDocumentsModal"; -import { uploadStandaloneDocument } from "@/app/lib/mikeApi"; -import { - SUPPORTED_DOCUMENT_ACCEPT, - formatUnsupportedDocumentWarning, - partitionSupportedDocumentFiles, -} from "@/app/lib/documentUploadValidation"; +import { AddDocumentsModal } from "../modals/AddDocumentsModal"; type AskInputsEvent = Extract; type AskInputItem = AskInputsEvent["items"][number]; @@ -48,34 +33,47 @@ export function AskInputPopup({ ); const [otherOpen, setOtherOpen] = useState>({}); const [otherValues, setOtherValues] = useState>({}); - const [docsByInput, setDocsByInput] = useState>( - {}, - ); + const [docsByInput, setDocsByInput] = useState< + Record> + >({}); const [skipped, setSkipped] = useState>(() => new Set()); + const [confirmed, setConfirmed] = useState>(() => new Set()); const [submitted, setSubmitted] = useState(false); const [dismissed, setDismissed] = useState(false); - const [uploadingInputId, setUploadingInputId] = useState( - null, - ); - const [dragActiveInputId, setDragActiveInputId] = useState( - null, - ); - const [uploadWarning, setUploadWarning] = useState(null); - const [docSelectorInputId, setDocSelectorInputId] = useState( - null, - ); + const [docSelectorTarget, setDocSelectorTarget] = useState<{ + inputId: string; + typeIndex: number; + } | null>(null); const [activeInputId, setActiveInputId] = useState( () => event.items[0]?.id ?? "", ); - const fileInputsRef = useRef>({}); + + const docsForItem = useCallback( + (inputId: string) => { + const seen = new Set(); + return Object.values(docsByInput[inputId] ?? {}) + .flat() + .filter((doc) => { + if (seen.has(doc.id)) return false; + seen.add(doc.id); + return true; + }); + }, + [docsByInput], + ); + + const itemAnswered = useCallback( + (item: AskInputItem) => { + if (item.kind === "choice") return !!choiceAnswers[item.id]?.trim(); + return docsForItem(item.id).length > 0; + }, + [choiceAnswers, docsForItem], + ); const itemResolved = useCallback( - (item: AskInputItem) => { - if (skipped.has(item.id)) return true; - if (item.kind === "choice") return !!choiceAnswers[item.id]?.trim(); - return (docsByInput[item.id] ?? []).length > 0; - }, - [choiceAnswers, docsByInput, skipped], + (item: AskInputItem) => + skipped.has(item.id) || confirmed.has(item.id), + [confirmed, skipped], ); const firstUnresolvedId = useCallback( @@ -105,55 +103,52 @@ export function AskInputPopup({ if (shouldSkip) goToNextUnresolved(id); }; - const addDocs = (inputId: string, selected: Document[]) => { + const confirmItem = (id: string) => { + setConfirmed((prev) => { + const next = new Set(prev); + next.add(id); + return next; + }); + goToNextUnresolved(id); + }; + + const addDocs = ( + inputId: string, + typeIndex: number, + selected: Document[], + ) => { if (selected.length === 0) return; setSkippedFor(inputId, false); setDocsByInput((prev) => { - const current = prev[inputId] ?? []; + const byType = prev[inputId] ?? {}; + const current = byType[typeIndex] ?? []; const existing = new Set(current.map((doc) => doc.id)); return { ...prev, - [inputId]: [ - ...current, - ...selected.filter((doc) => !existing.has(doc.id)), - ], + [inputId]: { + ...byType, + [typeIndex]: [ + ...current, + ...selected.filter((doc) => !existing.has(doc.id)), + ], + }, }; }); - goToNextUnresolved(inputId); }; - const removeDoc = (inputId: string, docId: string) => { - setDocsByInput((prev) => ({ - ...prev, - [inputId]: (prev[inputId] ?? []).filter((doc) => doc.id !== docId), - })); - }; - - const handleFiles = async (inputId: string, incomingFiles: File[]) => { - if (!incomingFiles.length || submitted) return; - const { supported, unsupported } = - partitionSupportedDocumentFiles(incomingFiles); - setUploadWarning(formatUnsupportedDocumentWarning(unsupported)); - if (supported.length === 0) { - const input = fileInputsRef.current[inputId]; - if (input) input.value = ""; - return; - } - setUploadingInputId(inputId); - try { - const uploaded = await Promise.all( - supported.map((file) => uploadStandaloneDocument(file)), - ); - invalidateDirectoryCache(); - addDocs(inputId, uploaded); - } catch (err) { - console.error("Document upload failed:", err); - } finally { - setUploadingInputId(null); - setDragActiveInputId(null); - const input = fileInputsRef.current[inputId]; - if (input) input.value = ""; - } + const removeDoc = (inputId: string, typeIndex: number, docId: string) => { + setDocsByInput((prev) => { + const byType = prev[inputId] ?? {}; + return { + ...prev, + [inputId]: { + ...byType, + [typeIndex]: (byType[typeIndex] ?? []).filter( + (doc) => doc.id !== docId, + ), + }, + }; + }); }; const chooseAnswer = ( @@ -164,16 +159,12 @@ export function AskInputPopup({ if (!trimmed || submitted) return; setSkippedFor(item.id, false); setChoiceAnswers((prev) => ({ ...prev, [item.id]: trimmed })); - goToNextUnresolved(item.id); + setOtherOpen((prev) => ({ ...prev, [item.id]: false })); }; const allResolved = event.items.length > 0 && event.items.every(itemResolved); - const canSubmit = - !submitted && - !uploadingInputId && - allResolved && - !!onSubmit; + const canSubmit = !submitted && allResolved && !!onSubmit; const buildResponse = (): AskInputsResponse => { const responses = event.items.map((item) => { @@ -203,9 +194,7 @@ export function AskInputPopup({ return { id: item.id, kind: "documents" as const, - filenames: (docsByInput[item.id] ?? []).map( - (doc) => doc.filename, - ), + filenames: docsForItem(item.id).map((doc) => doc.filename), }; }); return { type: "ask_inputs_response", responses }; @@ -222,7 +211,7 @@ export function AskInputPopup({ ) { return []; } - return docsByInput[item.id] ?? []; + return docsForItem(item.id); }); const seen = new Set(); return docs.flatMap((doc) => { @@ -279,43 +268,38 @@ export function AskInputPopup({ return ( <>

-
+
{submitted ? ( "Inputs sent" ) : ( -
- {event.items.map((item, index) => { +
+ {event.items.map((item) => { const isActive = item.id === activeItem?.id; const isResolved = itemResolved(item); const label = item.kind === "choice" - ? `Question ${index + 1}` - : "Add Documents"; + ? "Question" + : "Documents"; return ( - + {label} + ); })}
@@ -323,20 +307,21 @@ export function AskInputPopup({
{!submitted && ( - + + )}
{activeItem && (
-
+
{activeItem.kind === "choice" ? ( @@ -344,25 +329,9 @@ export function AskInputPopup({ {activeItem.question}

) : ( - + )}
- {!submitted && ( - - )}
@@ -386,18 +355,36 @@ export function AskInputPopup({ onAnswer={(answer) => chooseAnswer(activeItem, answer) } - onOtherOpen={() => + onOtherOpen={() => { setOtherOpen((prev) => ({ ...prev, [activeItem.id]: true, - })) - } - onOtherValue={(value) => + })); + setChoiceAnswers((prev) => ({ + ...prev, + [activeItem.id]: ( + otherValues[ + activeItem.id + ] ?? "" + ).trim(), + })); + }} + onOtherValue={(value) => { setOtherValues((prev) => ({ ...prev, [activeItem.id]: value, - })) - } + })); + setChoiceAnswers((prev) => ({ + ...prev, + [activeItem.id]: + value.trim(), + })); + if (value.trim()) + setSkippedFor( + activeItem.id, + false, + ); + }} /> ) : ( + setDocSelectorTarget({ + inputId: activeItem.id, + typeIndex, + }) } - dragActive={ - dragActiveInputId === - activeItem.id - } - fileInputRef={(node) => { - fileInputsRef.current[ - activeItem.id - ] = node; - }} - onFiles={(files) => - void handleFiles( + onRemoveDoc={(typeIndex, docId) => + removeDoc( activeItem.id, - files, + typeIndex, + docId, ) } - onDragActive={(active) => - setDragActiveInputId( - active - ? activeItem.id - : null, - ) - } - onBrowse={() => - setDocSelectorInputId( - activeItem.id, - ) - } - onRemoveDoc={(docId) => - removeDoc(activeItem.id, docId) - } /> )}
-
- )} - - {uploadWarning && ( -
- - - {uploadWarning} - - + {!submitted && ( +
+ + + confirmItem(activeItem.id) + } + className="h-6 px-3 font-sans text-[10px]" + > + {confirmed.has(activeItem.id) ? ( + <> + Confirmed + + + ) : ( + "Confirm" + )} + +
+ )}
)}
setDocSelectorInputId(null)} + open={!!docSelectorTarget} + keepMounted + onClose={() => setDocSelectorTarget(null)} onSelect={(selected) => { - if (docSelectorInputId) - addDocs(docSelectorInputId, selected); + if (!docSelectorTarget) return; + // A document can only be added once per input, so docs + // already living in another type row are left where they + // are; only genuinely new picks join the targeted row. + const existing = new Set( + docsForItem(docSelectorTarget.inputId).map( + (doc) => doc.id, + ), + ); + addDocs( + docSelectorTarget.inputId, + docSelectorTarget.typeIndex, + selected.filter((doc) => !existing.has(doc.id)), + ); }} breadcrumb={["Assistant", "Add Documents"]} + initialSelectedDocuments={ + docSelectorTarget + ? docsForItem(docSelectorTarget.inputId) + : [] + } /> ); @@ -502,23 +509,23 @@ function OptionInput({ onOtherValue: (value: string) => void; }) { return ( -
+
{item.options.map((option, idx) => { const answer = option.value.trim(); - const isSelected = selectedAnswer === answer.trim(); + const isSelected = !otherOpen && selectedAnswer === answer; return (