Compare commits

..
7 Commits
Author SHA1 Message Date
calcazar 33bcee7104 Merge pull request 'Promote to main: Red ring on every hex avatar' (#11) from develop into main 2026-10-21 13:12:58 -06:00
calcazar 7296907963 Merge pull request 'Promote to main: Keep the delete error clear of text-only quotes' (#9) from develop into main
Reviewed-on: minnect/frontend-helper#9
2026-10-20 10:52:06 -06:00
calcazar 3773a86a54 Merge pull request 'Promote to main: Feed browser luxury redesign' (#7) from develop into main
Reviewed-on: minnect/frontend-helper#7
2026-10-20 10:33:00 -06:00
calcazar d6244ba404 Promote to main: Fixes (sign-out removal, video playback) and Experts & trending
Validated in dev by Cesar. Clean build verified. Includes sign-out removal, feed video mp4 playback, 3-column layout with Featured experts + Trending minnects rails, expert search, category browse, and trending video-answer playback.
2026-10-19 17:43:20 -06:00
calcazar f4a8b0174b Promote to main: open-source README + MIT license and run-steps docs 2026-10-19 16:47:55 -06:00
calcazar 3e85f0c91e Promote to main: Likes/comments/text posts, Image/video posts, publish-lag fix
Validated in dev by Cesar; Feed browser module acceptance pass PASSED (mdi1xkyuv). Tasks 4wswt4ex2, 3dom4wf7p, bo5tym52p, a18hvcryt, mgply09ox, t3xbu10dr.
2026-10-19 16:13:35 -06:00
calcazar 2dfe001439 Promote to main: Sign-in and Read the home feed
Validated in dev by Cesar. Includes server session + sign-in + refresh/sign-out (kdlb9zs9n, tlv84yb7n, yarm6mesq) and the home feed proxy + cards + video (emrgilhzp, q12xo172i, vycxexra8).
2026-10-19 14:14:27 -06:00
2 changed files with 17 additions and 60 deletions
+17 -34
View File
@@ -78,19 +78,18 @@ function readPresigned(
): { uploadUrl: string; attachmentId: string; s3Key: string } | null { ): { uploadUrl: string; attachmentId: string; s3Key: string } | null {
const root = rec(body); const root = rec(body);
const data = root.data; const data = root.data;
// The confirmed shape is { data: { uploads: [{ attachmentId, presignedUrl, // The entry may be the first element of an array, the first element of a
// s3Key, index }] } }. The entry may also arrive as a bare array, under a // named list, or the data object itself.
// differently named list, or as the data object itself, so try each.
const candidates: unknown[] = []; const candidates: unknown[] = [];
if (Array.isArray(data)) candidates.push(...data); if (Array.isArray(data)) candidates.push(...data);
else if (Array.isArray(body)) candidates.push(...(body as unknown[])); else if (Array.isArray(body)) candidates.push(...(body as unknown[]));
// Named lists can live under `data` or at the root. else {
for (const container of [rec(data), root]) { const d = rec(data);
for (const key of ["uploads", "files", "urls", "attachments", "presignedUrls"]) { for (const key of ["files", "urls", "attachments", "presignedUrls"]) {
if (Array.isArray(container[key])) candidates.push(...(container[key] as unknown[])); if (Array.isArray(d[key])) candidates.push(...(d[key] as unknown[]));
} }
candidates.push(d);
} }
candidates.push(rec(data));
for (const c of candidates) { for (const c of candidates) {
const e = rec(c); const e = rec(c);
const uploadUrl = const uploadUrl =
@@ -190,21 +189,7 @@ export async function runMediaPipeline(
options: RefreshOptions = {}, options: RefreshOptions = {},
): Promise<CreateMediaPostResult> { ): Promise<CreateMediaPostResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const; const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const failedResult = { ok: false, status: 502, error: "post_failed" } as const; const failed = { ok: false, status: 502, error: "post_failed" } as const;
// Log which step failed with its status and Minnect's message, never a token,
// presigned URL or file bytes, so a failed publish can be diagnosed.
const fail = async (step: string, res?: Response, note?: string) => {
let message = note ?? "";
if (res) {
const body = rec(await res.clone().json().catch(() => null));
const m = body.message ?? body.error ?? rec(body.error).message;
message = [`status ${res.status}`, typeof m === "string" ? m : "", note ?? ""]
.filter(Boolean)
.join(" ");
}
console.warn(`[post-media] ${mediaType} ${step} failed: ${message}`);
return failedResult;
};
const doFetch = options.fetchImpl ?? fetch; const doFetch = options.fetchImpl ?? fetch;
const { file, mediaType } = upload; const { file, mediaType } = upload;
@@ -237,9 +222,9 @@ export async function runMediaPipeline(
visibility: "public", visibility: "public",
}); });
if (created === "sign_in") return signIn; if (created === "sign_in") return signIn;
if (created.status !== 200 && created.status !== 201) return fail("draft", created); if (created.status !== 200 && created.status !== 201) return failed;
const info = readCreated(await created.json().catch(() => null)); const info = readCreated(await created.json().catch(() => null));
if (!info) return fail("draft", undefined, "no post id in response"); if (!info) return failed;
// 2. Presigned URL for the one file. // 2. Presigned URL for the one file.
const presignedRes = await call("/api/v1/attachments/presigned-urls", { const presignedRes = await call("/api/v1/attachments/presigned-urls", {
@@ -247,9 +232,9 @@ export async function runMediaPipeline(
files: [{ name: file.name, type: file.type, size: file.size }], files: [{ name: file.name, type: file.type, size: file.size }],
}); });
if (presignedRes === "sign_in") return signIn; if (presignedRes === "sign_in") return signIn;
if (presignedRes.status < 200 || presignedRes.status > 299) return fail("presign", presignedRes); if (presignedRes.status < 200 || presignedRes.status > 299) return failed;
const slot = readPresigned(await presignedRes.json().catch(() => null)); const slot = readPresigned(await presignedRes.json().catch(() => null));
if (!slot) return fail("presign", undefined, "no upload slot in response"); if (!slot) return failed;
// 3. The server PUTs the bytes. No Authorization header: the presigned URL // 3. The server PUTs the bytes. No Authorization header: the presigned URL
// is pre-authorized, and the feed token must never reach S3 or the browser. // is pre-authorized, and the feed token must never reach S3 or the browser.
@@ -259,7 +244,7 @@ export async function runMediaPipeline(
headers: { "Content-Type": file.type }, headers: { "Content-Type": file.type },
body: bytes, body: bytes,
}); });
if (!put.ok) return fail("upload", undefined, `status ${put.status}`); if (!put.ok) return failed;
// 4. Confirm (image confirmation blocks until processing finishes). // 4. Confirm (image confirmation blocks until processing finishes).
const confirmed = await call("/api/v1/attachments/confirm-uploads", { const confirmed = await call("/api/v1/attachments/confirm-uploads", {
@@ -274,19 +259,17 @@ export async function runMediaPipeline(
], ],
}); });
if (confirmed === "sign_in") return signIn; if (confirmed === "sign_in") return signIn;
if (confirmed.status < 200 || confirmed.status > 299) return fail("confirm", confirmed); if (confirmed.status < 200 || confirmed.status > 299) return failed;
// 5. Publish, same call as a text post. // 5. Publish, same call as a text post.
const published = await call(`/api/v1/posts/${encodeURIComponent(info.id)}/publish`, { const published = await call(`/api/v1/posts/${encodeURIComponent(info.id)}/publish`, {
content: caption, content: caption,
}); });
if (published === "sign_in") return signIn; if (published === "sign_in") return signIn;
if (published.status < 200 || published.status > 299) return fail("publish", published); if (published.status < 200 || published.status > 299) return failed;
return { ok: true, id: info.id, status: info.status }; return { ok: true, id: info.id, status: info.status };
} catch (e) { } catch {
const code = e instanceof Error ? (e.cause as { code?: unknown } | undefined)?.code : undefined; return failed;
const note = e instanceof Error ? `${e.name}: ${e.message} ${code ?? ""}`.trim() : "error";
return fail("pipeline", undefined, note);
} }
} }
-26
View File
@@ -100,32 +100,6 @@ describe("POST /api/posts (image)", () => {
expect(calls.filter((c) => c.url.includes("/api/v1/")).every((c) => c.auth === "Bearer T")).toBe(true); expect(calls.filter((c) => c.url.includes("/api/v1/")).every((c) => c.auth === "Bearer T")).toBe(true);
}); });
it("reads the real { data: { uploads: [{ presignedUrl, ... }] } } shape", async () => {
const sid = session();
const calls: { url: string; method: string }[] = [];
const fn = vi.fn(async (url: string | URL, init?: RequestInit) => {
const u = String(url);
calls.push({ url: u, method: String(init?.method ?? "GET") });
if (u.endsWith("/api/v1/posts")) return jsonRes({ data: { id: "p9", status: "draft" } }, 201);
if (u.endsWith("/attachments/presigned-urls"))
return jsonRes(
{ data: { uploads: [{ attachmentId: "a1", presignedUrl: "https://s3.example/put?sig=9", s3Key: "k/1.jpg", index: 0 }] } },
200,
);
if (u === "https://s3.example/put?sig=9") return new Response(null, { status: 200 });
if (u.endsWith("/attachments/confirm-uploads")) return jsonRes({ data: { success: true } }, 200);
if (u.includes("/publish")) return jsonRes({ data: { success: true } }, 200);
return new Response("{}", { status: 404 });
});
const res = await withFetch(fn as unknown as typeof fetch, () =>
POST(multipart("look", imageFile(), sid)),
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: "p9" });
const put = calls.find((c) => c.method === "PUT");
expect(put?.url).toBe("https://s3.example/put?sig=9");
});
it("never returns the feed token to the browser", async () => { it("never returns the feed token to the browser", async () => {
const sid = session(); const sid = session();
const fn = vi.fn(async (url: string | URL) => { const fn = vi.fn(async (url: string | URL) => {