Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions app/api/bookmarks/[userId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/firebase-admin";
import { toIso } from "@/lib/firestore-helpers";

export const runtime = "nodejs";

export async function GET(
_req: NextRequest,
context: { params: Promise<{ userId: string }> }
) {
try {
const { userId } = await context.params;
if (!userId) {
return NextResponse.json({ error: "Missing userId" }, { status: 400 });
}

const snapshot = await db
.collection("messages")
.where("bookmarks", "array-contains", userId)
.orderBy("createdAt", "desc")
.limit(100)
.get();

const messages = snapshot.docs.map((doc: any) => {
const data = doc.data();
return {
_id: doc.id,
...data,
createdAt: toIso(data.createdAt),
updatedAt: toIso(data.updatedAt),
ghostExpiresAt: toIso(data.ghostExpiresAt),
};
});

return NextResponse.json(messages);
} catch (error) {
console.error("GET /api/bookmarks/[userId] error:", error);
return NextResponse.json(
{ error: "Failed to load bookmarks" },
{ status: 500 }
);
}
}
82 changes: 82 additions & 0 deletions app/api/chat/upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { v2 as cloudinary } from "cloudinary";

export const runtime = "nodejs";

const MAX_FILE_SIZE = 25 * 1024 * 1024;

cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
secure: true,
});

function resourceTypeFor(type: string) {
if (type.startsWith("image/")) return "image";
if (type.startsWith("video/")) return "video";
return "raw";
}

export async function POST(req: NextRequest) {
try {
if (
!process.env.CLOUDINARY_CLOUD_NAME ||
!process.env.CLOUDINARY_API_KEY ||
!process.env.CLOUDINARY_API_SECRET
) {
return NextResponse.json(
{ error: "Cloudinary is not configured" },
{ status: 500 }
);
}

const form = await req.formData();
const file = form.get("file");

if (!(file instanceof File)) {
return NextResponse.json({ error: "file is required" }, { status: 400 });
}

if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: "File must be 25MB or smaller" },
{ status: 413 }
);
}

const bytes = Buffer.from(await file.arrayBuffer());
const resourceType = resourceTypeFor(file.type);

const result = await new Promise<any>((resolve, reject) => {
const stream = cloudinary.uploader.upload_stream(
{
folder: "vibexcode/chat",
resource_type: resourceType,
use_filename: true,
unique_filename: true,
},
(error, uploadResult) => {
if (error) reject(error);
else resolve(uploadResult);
}
);
stream.end(bytes);
});

return NextResponse.json({
url: result.secure_url,
secureUrl: result.secure_url,
publicId: result.public_id,
name: file.name,
type: file.type || "application/octet-stream",
size: file.size,
format: result.format || "",
resourceType,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to upload file";
return NextResponse.json({ error: message }, { status: 500 });
}
}
6 changes: 5 additions & 1 deletion app/api/dev/users/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { NextResponse } from "next/server";
import { db } from "@/lib/firebase-admin";
import { derivePresence } from "@/lib/presence";
import { derivePresence, derivePresenceDevice } from "@/lib/presence";
import { toDate } from "@/lib/firestore-helpers";

type LeanUser = {
Expand All @@ -13,6 +13,8 @@ type LeanUser = {
username?: string;
name?: string;
lastSeen?: string | Date;
presenceDevice?: string;
customStatus?: string;
activity?: string;
stats?: { totalSolved?: number };
createdAt?: string | Date;
Expand Down Expand Up @@ -46,6 +48,8 @@ export async function GET() {
const decorated = users.map((u: any) => ({
...u,
status: derivePresence(u.lastSeen || null),
presenceDevice: derivePresenceDevice(u.presenceDevice),
customStatus: u.customStatus || "",
}));

return NextResponse.json(decorated);
Expand Down
66 changes: 66 additions & 0 deletions app/api/message/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,69 @@ export async function DELETE(
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

export async function PATCH(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const { senderId, action, emoji } = await req.json();

if (!senderId || !action) {
return NextResponse.json(
{ error: "Missing required fields (senderId or action)" },
{ status: 400 }
);
}

const msgRef = db.collection("messages").doc(id);
const msgSnap = await msgRef.get();
if (!msgSnap.exists) {
return NextResponse.json({ error: "Message not found" }, { status: 404 });
}

const data = msgSnap.data() || {};

if (action === "reaction") {
const safeEmoji = String(emoji || "").slice(0, 8);
if (!safeEmoji) {
return NextResponse.json({ error: "emoji is required" }, { status: 400 });
}

const reactions = { ...(data.reactions || {}) } as Record<string, string[]>;
const current = Array.isArray(reactions[safeEmoji])
? reactions[safeEmoji]
: [];
reactions[safeEmoji] = current.includes(senderId)
? current.filter((id) => id !== senderId)
: [...current, senderId];

if (reactions[safeEmoji].length === 0) delete reactions[safeEmoji];

await msgRef.set(
{ reactions, updatedAt: FieldValue.serverTimestamp() },
{ merge: true }
);
return NextResponse.json({ success: true, reactions });
}

if (action === "bookmark") {
const bookmarks = Array.isArray(data.bookmarks) ? data.bookmarks : [];
const nextBookmarks = bookmarks.includes(senderId)
? bookmarks.filter((id: string) => id !== senderId)
: [...bookmarks, senderId];

await msgRef.set(
{ bookmarks: nextBookmarks, updatedAt: FieldValue.serverTimestamp() },
{ merge: true }
);
return NextResponse.json({ success: true, bookmarks: nextBookmarks });
}

return NextResponse.json({ error: "Unsupported action" }, { status: 400 });
} catch (error) {
console.error("PATCH /api/message/[id] error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
64 changes: 58 additions & 6 deletions app/api/messages/[conversationId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@ type UserRecord = {
moderation?: ModerationState;
};

const GHOST_TTL_MS = 24 * 60 * 60 * 1000;

async function cleanupExpiredMessages(conversationId: string) {
const expired = await db
.collection("messages")
.where("conversation", "==", conversationId)
.where("ghostExpiresAt", "<=", Timestamp.now())
.limit(50)
.get();

if (expired.empty) return;

const batch = db.batch();
expired.docs.forEach((doc: any) => batch.delete(doc.ref));
await batch.commit();
}

async function resolveUser(
senderId: string,
senderEmail?: string,
Expand Down Expand Up @@ -86,6 +103,8 @@ export async function GET(
return NextResponse.json({ error: "Missing conversationId" }, { status: 400 });
}

await cleanupExpiredMessages(conversationId);

const snapshot = await db
.collection("messages")
.where("conversation", "==", conversationId)
Expand All @@ -99,6 +118,7 @@ export async function GET(
...data,
createdAt: toIso(data.createdAt),
updatedAt: toIso(data.updatedAt),
ghostExpiresAt: toIso(data.ghostExpiresAt),
};
});

Expand All @@ -121,9 +141,17 @@ export async function POST(
}

const body = await req.json();
const { senderId, senderName, senderEmail, body: messageBody, image } = body;
const {
senderId,
senderName,
senderEmail,
body: messageBody,
image,
ghost,
attachments,
} = body;

if (!senderId || !messageBody) {
if (!senderId || (!messageBody && !Array.isArray(attachments))) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}

Expand All @@ -147,7 +175,7 @@ export async function POST(
);
}

const abuse = detectAbuse(String(messageBody));
const abuse = detectAbuse(String(messageBody || ""));
if (abuse.hit) {
const windowStart = new Date(now.getTime() - WARNING_WINDOW_MS);
const warnings = user.moderation?.warnings || [];
Expand Down Expand Up @@ -201,14 +229,37 @@ export async function POST(
);
}

await cleanupExpiredMessages(conversationId);

const nowTs = Timestamp.now();
const ghostExpiresAt = ghost
? Timestamp.fromMillis(nowTs.toMillis() + GHOST_TTL_MS)
: null;
const safeAttachments = Array.isArray(attachments)
? attachments.slice(0, 6).map((file: any) => ({
url: String(file?.url || ""),
secureUrl: String(file?.secureUrl || file?.url || ""),
name: String(file?.name || "Attachment").slice(0, 160),
type: String(file?.type || "file").slice(0, 80),
size: Number(file?.size || 0),
format: String(file?.format || "").slice(0, 40),
resourceType: String(file?.resourceType || "raw").slice(0, 20),
}))
: [];

const messageRef = await db.collection("messages").add({
conversation: conversationId,
sender: senderId,
senderName: senderName || "",
body: messageBody,
body: messageBody || "",
image: image || "",
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
ghost: Boolean(ghost),
ghostExpiresAt,
attachments: safeAttachments,
reactions: {},
bookmarks: [],
createdAt: nowTs,
updatedAt: nowTs,
});

const created = await messageRef.get();
Expand All @@ -220,6 +271,7 @@ export async function POST(
...data,
createdAt: toIso(data.createdAt),
updatedAt: toIso(data.updatedAt),
ghostExpiresAt: toIso(data.ghostExpiresAt),
},
{ status: 201 }
);
Expand Down
Loading
Loading