Skip to content

Commit d39f580

Browse files
authored
Merge pull request #148 from willchen96/document-ui-tabular-updates
Update document UI, tabular reviews, and storage caching
2 parents 2bbb628 + 4f33843 commit d39f580

26 files changed

Lines changed: 853 additions & 338 deletions

CONTRIBUTING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
2020
- why
2121
- testing
2222

23+
## Security
24+
25+
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/willchen96/mike/security/advisories/new) instead.
26+
27+
We will aim to respond promptly and coordinate a disclosure timeline with you.
28+
2329
## Local Development
2430

2531
Backend:

backend/schema.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ create table if not exists public.tabular_reviews (
283283
user_id text not null,
284284
title text,
285285
columns_config jsonb,
286+
document_ids jsonb,
286287
workflow_id uuid references public.workflows(id) on delete set null,
287288
practice text,
288289
shared_with jsonb not null default '[]'::jsonb,

backend/src/lib/convert.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { promisify } from "util";
21
import JSZip from "jszip";
32

43
let _convert:
@@ -8,7 +7,26 @@ let _convert:
87
async function getConvert() {
98
if (!_convert) {
109
const libre = await import("libreoffice-convert");
11-
_convert = promisify(libre.default.convert.bind(libre.default));
10+
const convert = libre.default.convert.bind(libre.default) as (
11+
buf: Buffer,
12+
ext: string,
13+
filter: undefined,
14+
callback?: (err: Error | null, result: Buffer) => void,
15+
) => Promise<Buffer> | void;
16+
_convert = (buf, ext, filter) =>
17+
new Promise<Buffer>((resolve, reject) => {
18+
try {
19+
const maybePromise = convert(buf, ext, filter, (err, result) => {
20+
if (err) reject(err);
21+
else resolve(result);
22+
});
23+
if (maybePromise && typeof maybePromise.then === "function") {
24+
maybePromise.then(resolve, reject);
25+
}
26+
} catch (err) {
27+
reject(err);
28+
}
29+
});
1230
}
1331
return _convert;
1432
}

backend/src/lib/storage.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,21 @@ import {
1717
} from "@aws-sdk/client-s3";
1818
import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner";
1919

20+
let cachedClient: S3Client | undefined;
21+
2022
function getClient(): S3Client {
21-
return new S3Client({
22-
region: "auto",
23-
endpoint: process.env.R2_ENDPOINT_URL!,
24-
forcePathStyle: true,
25-
credentials: {
26-
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
27-
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
28-
},
29-
});
23+
if (!cachedClient) {
24+
cachedClient = new S3Client({
25+
region: "auto",
26+
endpoint: process.env.R2_ENDPOINT_URL!,
27+
forcePathStyle: true,
28+
credentials: {
29+
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
30+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
31+
},
32+
});
33+
}
34+
return cachedClient;
3035
}
3136

3237
const BUCKET = process.env.R2_BUCKET_NAME ?? "mike";
@@ -37,6 +42,14 @@ export const storageEnabled = Boolean(
3742
process.env.R2_SECRET_ACCESS_KEY,
3843
);
3944

45+
function requireStorageConfig(): void {
46+
if (!storageEnabled) {
47+
throw new Error(
48+
"R2_ENDPOINT_URL, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY must be set",
49+
);
50+
}
51+
}
52+
4053
// ---------------------------------------------------------------------------
4154
// Upload
4255
// ---------------------------------------------------------------------------
@@ -46,6 +59,7 @@ export async function uploadFile(
4659
content: ArrayBuffer,
4760
contentType: string,
4861
): Promise<void> {
62+
requireStorageConfig();
4963
const client = getClient();
5064
await client.send(
5165
new PutObjectCommand({

backend/src/routes/chat.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ async function getAccessibleChat(
141141
chatRouter.get("/", requireAuth, async (req, res) => {
142142
const userId = res.locals.userId as string;
143143
const db = createServerSupabase();
144+
const requestedLimit = Number.parseInt(String(req.query.limit ?? ""), 10);
145+
const limit = Number.isFinite(requestedLimit)
146+
? Math.min(Math.max(requestedLimit, 1), 100)
147+
: null;
144148

145149
const { data: ownProjects, error: projErr } = await db
146150
.from("projects")
@@ -156,11 +160,15 @@ chatRouter.get("/", requireAuth, async (req, res) => {
156160
? `user_id.eq.${userId},project_id.in.(${ownProjectIds.join(",")})`
157161
: `user_id.eq.${userId}`;
158162

159-
const { data, error } = await db
163+
let query = db
160164
.from("chats")
161165
.select("*")
162166
.or(filter)
163167
.order("created_at", { ascending: false });
168+
169+
if (limit) query = query.limit(limit);
170+
171+
const { data, error } = await query;
164172
if (error) return void res.status(500).json({ detail: error.message });
165173
res.json(data ?? []);
166174
});

backend/src/routes/tabular.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,15 @@ tabularRouter.get("/", requireAuth, async (req, res) => {
165165
// Fetch distinct document counts per review
166166
const reviewIds = reviews.map((r) => (r as { id: string }).id);
167167
let docCounts: Record<string, number> = {};
168+
const reviewsWithExplicitDocs = new Set<string>();
169+
for (const review of reviews) {
170+
const id = (review as { id: string }).id;
171+
if (Array.isArray(review.document_ids)) {
172+
const explicitDocIds = review.document_ids;
173+
reviewsWithExplicitDocs.add(id);
174+
docCounts[id] = new Set(explicitDocIds).size;
175+
}
176+
}
168177
if (reviewIds.length > 0) {
169178
const { data: cells } = await db
170179
.from("tabular_cells")
@@ -176,8 +185,10 @@ tabularRouter.get("/", requireAuth, async (req, res) => {
176185
const key = `${cell.review_id}:${cell.document_id}`;
177186
if (!seen.has(key)) {
178187
seen.add(key);
179-
docCounts[cell.review_id] =
180-
(docCounts[cell.review_id] ?? 0) + 1;
188+
if (!reviewsWithExplicitDocs.has(cell.review_id)) {
189+
docCounts[cell.review_id] =
190+
(docCounts[cell.review_id] ?? 0) + 1;
191+
}
181192
}
182193
}
183194
}
@@ -229,6 +240,7 @@ tabularRouter.post("/", requireAuth, async (req, res) => {
229240
user_id: userId,
230241
title: title ?? null,
231242
columns_config,
243+
document_ids: allowedDocumentIds,
232244
project_id: project_id ?? null,
233245
workflow_id: workflow_id ?? null,
234246
})
@@ -345,17 +357,19 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => {
345357
.from("tabular_cells")
346358
.select("*")
347359
.eq("review_id", reviewId);
348-
const docIds = [...new Set((cells ?? []).map((c) => c.document_id))];
360+
const cellDocIds = [...new Set((cells ?? []).map((c) => c.document_id))];
361+
const hasExplicitDocIds = Array.isArray(review.document_ids);
362+
const explicitDocIds = hasExplicitDocIds
363+
? (review.document_ids as string[])
364+
: [];
365+
const docIds =
366+
hasExplicitDocIds
367+
? explicitDocIds
368+
: cellDocIds;
349369
const docsResult =
350370
docIds.length > 0
351371
? await db.from("documents").select("*").in("id", docIds)
352-
: review.project_id
353-
? await db
354-
.from("documents")
355-
.select("*")
356-
.eq("project_id", review.project_id)
357-
.order("created_at", { ascending: true })
358-
: { data: [] as Record<string, unknown>[] };
372+
: { data: [] as Record<string, unknown>[] };
359373

360374
res.json({
361375
review: { ...review, is_owner: access.isOwner },
@@ -517,6 +531,7 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
517531
detail: updateError?.message ?? "Failed to update review",
518532
});
519533

534+
let persistedDocumentIds: string[] | undefined;
520535
if (
521536
Array.isArray(req.body.columns_config) ||
522537
Array.isArray(req.body.document_ids)
@@ -577,13 +592,21 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
577592
(existingCells ?? []).map((cell) => cell.document_id),
578593
),
579594
];
580-
if (documentIds.length === 0 && existingReview.project_id) {
581-
const { data: projectDocs } = await db
582-
.from("documents")
583-
.select("id")
584-
.eq("project_id", existingReview.project_id);
585-
documentIds = (projectDocs ?? []).map((doc) => doc.id);
586-
}
595+
}
596+
597+
if (Array.isArray(req.body.document_ids)) {
598+
persistedDocumentIds = documentIds;
599+
const { error: documentIdsError } = await db
600+
.from("tabular_reviews")
601+
.update({
602+
document_ids: documentIds,
603+
updated_at: new Date().toISOString(),
604+
})
605+
.eq("id", reviewId);
606+
if (documentIdsError)
607+
return void res.status(500).json({
608+
detail: documentIdsError.message,
609+
});
587610
}
588611

589612
const activeColumns = Array.isArray(req.body.columns_config)
@@ -614,7 +637,10 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
614637
}
615638
}
616639

617-
res.json(updatedReview);
640+
res.json({
641+
...updatedReview,
642+
...(persistedDocumentIds ? { document_ids: persistedDocumentIds } : {}),
643+
});
618644
});
619645

620646
// DELETE /tabular-review/:reviewId

frontend/src/app/(pages)/layout.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState, useEffect } from "react";
44
import { useRouter } from "next/navigation";
5-
import { Menu } from "lucide-react";
5+
import { PanelLeft } from "lucide-react";
66
import { useAuth } from "@/contexts/AuthContext";
77
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
88
import { SidebarContext } from "@/app/contexts/SidebarContext";
@@ -77,7 +77,12 @@ export default function MikeLayout({
7777
return (
7878
<ChatHistoryProvider>
7979
<SidebarContext.Provider
80-
value={{ setSidebarOpen: (open) => { setIsSidebarOpen(open); setIsSidebarOpenDesktop(open); } }}
80+
value={{
81+
setSidebarOpen: (open) => {
82+
setIsSidebarOpen(open);
83+
setIsSidebarOpenDesktop(open);
84+
},
85+
}}
8186
>
8287
<div className="h-dvh bg-white flex flex-col">
8388
<div className="flex-1 flex overflow-hidden">
@@ -87,12 +92,14 @@ export default function MikeLayout({
8792
/>
8893
<div className="flex-1 flex flex-col h-dvh md:overflow-hidden relative w-full">
8994
{/* Mobile header */}
90-
<div className="flex md:hidden items-center gap-3 px-4 py-3 border-b border-gray-100 shrink-0">
95+
<div className="flex md:hidden items-center gap-3 px-4 pt-3 pb-1 shrink-0">
9196
<button
9297
onClick={handleSidebarToggle}
93-
className="flex items-center justify-center w-8 h-8 rounded hover:bg-gray-100 text-gray-500 transition-colors"
98+
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/70 text-gray-700 shadow-[0_8px_24px_rgba(15,23,42,0.12)] ring-1 ring-white/70 backdrop-blur-md transition-all hover:bg-white/90 active:scale-95"
99+
title="Open sidebar"
100+
aria-label="Open sidebar"
94101
>
95-
<Menu className="h-5 w-5" />
102+
<PanelLeft className="h-4 w-4" />
96103
</button>
97104
</div>
98105
<main className="flex-1 overflow-y-auto md:overflow-hidden w-full h-full">

frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,25 +84,26 @@ function isDocxTab(filename: string) {
8484
return ext === "docx" || ext === "doc";
8585
}
8686

87-
const ICON_SIZE = 30;
87+
const ICON_SIZE = 28;
8888
const GAP = 14;
8989
const EXPLORER_MIN = 160;
9090
const EXPLORER_DEFAULT = 280;
9191
const CHAT_MIN = 320;
9292
const CHAT_DEFAULT = 420;
9393

9494
function AssistantGreeting({ username }: { username: string }) {
95+
const { profile } = useUserProfile();
9596
const [loaded, setLoaded] = useState(false);
9697
const [iconOffset, setIconOffset] = useState(0);
9798
const [textOffset, setTextOffset] = useState(0);
9899
const textRef = useRef<HTMLHeadingElement>(null);
99100

100101
useLayoutEffect(() => {
101-
if (!textRef.current) return;
102+
if (!profile || !textRef.current) return;
102103
const h1Width = textRef.current.offsetWidth;
103104
setIconOffset((h1Width + GAP) / 2);
104105
setTextOffset((ICON_SIZE + GAP) / 2);
105-
}, [username]);
106+
}, [profile]);
106107

107108
useEffect(() => {
108109
if (!iconOffset) return;
@@ -112,7 +113,7 @@ function AssistantGreeting({ username }: { username: string }) {
112113

113114
return (
114115
<div className="flex-1 flex items-center justify-center">
115-
<div className="relative flex items-center justify-center h-[30px]">
116+
<div className="relative flex items-center justify-center h-[28px]">
116117
<div
117118
className="absolute h-[30px]"
118119
style={{
@@ -128,7 +129,7 @@ function AssistantGreeting({ username }: { username: string }) {
128129
</div>
129130
<h1
130131
ref={textRef}
131-
className="absolute text-2xl font-serif font-light text-gray-900 whitespace-nowrap"
132+
className="absolute text-3xl font-serif font-light text-gray-900 whitespace-nowrap"
132133
style={{
133134
left: "50%",
134135
transform: loaded
@@ -309,9 +310,9 @@ export default function ProjectAssistantChatPage({ params }: Props) {
309310
`created=${created.sort().join(",")}`,
310311
`replicated=${replicated.sort().join(",")}`,
311312
`edited=${Object.entries(editedPerDoc)
312-
.map(([k, v]) => `${k}=${v}`)
313-
.sort()
314-
.join(",")}`,
313+
.map(([k, v]) => `${k}=${v}`)
314+
.sort()
315+
.join(",")}`,
315316
].join("|");
316317
}, [messages]);
317318

@@ -1007,8 +1008,9 @@ export default function ProjectAssistantChatPage({ params }: Props) {
10071008
<div
10081009
key={tab.documentId}
10091010
ref={(el) => {
1010-
tabItemRefs.current[tab.documentId] =
1011-
el;
1011+
tabItemRefs.current[
1012+
tab.documentId
1013+
] = el;
10121014
}}
10131015
onClick={() =>
10141016
switchTab(tab.documentId)

0 commit comments

Comments
 (0)