Replace PostgreSQL and Docker deployment with embedded Turso, Drizzle relational queries v2, native systemd deployment on port 43821, and fresh SQLite migrations.

Hop-State: A_06FP5KEDBTB4A9ZT7QB498G
Hop-Proposal: R_06FP5KDWMCKVVMQZT4MVZ28
Hop-Task: T_06FP5DZ7T0G45FG93PT90B8
Hop-Attempt: AT_06FP5DZ7T0PKQW99V27JCV8
This commit is contained in:
2026-07-14 15:44:42 -07:00
committed by Hop
184 changed files with 8375 additions and 53944 deletions
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { db, handleRegistry } from '@/db';
import { desc, eq, gt } from 'drizzle-orm';
import { desc } from 'drizzle-orm';
import { normalizeHandle } from '@/lib/federation/handles';
export async function GET(request: Request) {
@@ -17,7 +17,7 @@ export async function GET(request: Request) {
if (handleParam) {
const cleanHandle = normalizeHandle(handleParam);
const entry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.handle, cleanHandle),
where: { handle: cleanHandle },
});
if (!entry) {
@@ -36,8 +36,8 @@ export async function GET(request: Request) {
const sinceDate = sinceParam ? new Date(sinceParam) : null;
const entries = await db.query.handleRegistry.findMany({
where: sinceDate ? gt(handleRegistry.updatedAt, sinceDate) : undefined,
orderBy: [desc(handleRegistry.updatedAt)],
where: sinceDate ? { updatedAt: { gt: sinceDate } } : undefined,
orderBy: () => [desc(handleRegistry.updatedAt)],
limit,
});
+1 -192
View File
@@ -38,29 +38,6 @@ export default function AdminPage() {
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
const [isUploadingFavicon, setIsUploadingFavicon] = useState(false);
const [faviconUploadError, setFaviconUploadError] = useState<string | null>(null);
const [updateStatus, setUpdateStatus] = useState<{
current: { version: string; commit: string | null; buildDate: string | null };
latest: { version: string; commit: string | null; buildDate: string | null } | null;
updateAvailable: boolean;
updater: {
available: boolean;
status: string;
message?: string;
lastStartedAt?: string | null;
lastFinishedAt?: string | null;
lastExitCode?: number | null;
lastError?: string | null;
trigger?: 'manual' | 'auto' | null;
config?: {
autoUpdateEnabled: boolean;
intervalMinutes: number;
};
};
} | null>(null);
const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false);
const [triggeringUpdate, setTriggeringUpdate] = useState(false);
const [savingAutoUpdate, setSavingAutoUpdate] = useState(false);
useEffect(() => {
fetch('/api/admin/me')
.then((res) => res.json())
@@ -93,42 +70,12 @@ export default function AdminPage() {
}
};
const loadUpdateStatus = async () => {
setLoadingUpdateStatus(true);
try {
const res = await fetch('/api/admin/update', { cache: 'no-store' });
if (!res.ok) {
throw new Error('Failed to load update status');
}
const data = await res.json();
setUpdateStatus(data);
} catch {
setUpdateStatus(null);
} finally {
setLoadingUpdateStatus(false);
}
};
useEffect(() => {
if (isAdmin) {
loadNodeSettings();
loadUpdateStatus();
}
}, [isAdmin]);
useEffect(() => {
if (!isAdmin) {
return;
}
const interval = window.setInterval(() => {
loadUpdateStatus();
}, 30000);
return () => window.clearInterval(interval);
}, [isAdmin]);
const handleSaveSettings = async (override?: typeof nodeSettings) => {
const payload = override ?? nodeSettings;
setSavingSettings(true);
@@ -234,33 +181,6 @@ export default function AdminPage() {
setBannerPromptError('');
};
const handleTriggerUpdate = async () => {
setTriggeringUpdate(true);
try {
const res = await fetch('/api/admin/update', { method: 'POST', keepalive: true });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || 'Failed to start update');
}
showToast(data.message || 'Update started. Synapsis will restart shortly.', 'success');
await loadUpdateStatus();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start update';
if (message.toLowerCase().includes('fetch') || message.toLowerCase().includes('network')) {
showToast('Update likely started. The node is restarting, so this page may disconnect briefly.', 'success');
window.setTimeout(() => {
window.location.reload();
}, 5000);
} else {
showToast(message, 'error');
}
} finally {
setTriggeringUpdate(false);
}
};
const handleLogoUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
@@ -297,38 +217,6 @@ export default function AdminPage() {
}
};
const handleToggleAutoUpdate = async () => {
if (!updateStatus?.updater.available || !updateStatus.updater.config) return;
const nextValue = !updateStatus.updater.config.autoUpdateEnabled;
setSavingAutoUpdate(true);
try {
const res = await fetch('/api/admin/update', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoUpdateEnabled: nextValue }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || 'Failed to update auto-update setting');
}
setUpdateStatus((prev) => prev ? {
...prev,
updater: {
...prev.updater,
config: data.config,
},
} : prev);
showToast(nextValue ? 'Automatic updates enabled' : 'Automatic updates disabled', 'success');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Failed to update auto-update setting', 'error');
} finally {
setSavingAutoUpdate(false);
}
};
const handleFaviconUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
@@ -703,86 +591,7 @@ export default function AdminPage() {
</button>
</div>
<div style={{
padding: '16px',
background: 'var(--background-secondary)',
borderRadius: '8px',
border: '1px solid var(--border)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '16px', flexWrap: 'wrap' }}>
<div>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '6px' }}>System Update</h2>
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', margin: 0 }}>
Keep this node on the latest published Synapsis build.
</p>
</div>
<button
className="btn btn-primary"
onClick={handleTriggerUpdate}
disabled={
triggeringUpdate ||
loadingUpdateStatus ||
updateStatus?.updater.available === false ||
updateStatus?.updater.status === 'updating' ||
!updateStatus?.updateAvailable
}
>
{triggeringUpdate || updateStatus?.updater.status === 'updating' ? 'Updating...' : 'Update Now'}
</button>
</div>
{updateStatus?.updater.available && updateStatus?.updater.config && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--background)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '16px',
}}>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '4px' }}>
Automatic updates
</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-secondary)' }}>
Enabled by default. This node checks for updates every {updateStatus.updater.config.intervalMinutes} minutes and installs them automatically.
</div>
</div>
<button
className={`btn btn-sm ${updateStatus.updater.config.autoUpdateEnabled ? 'btn-primary' : 'btn-ghost'}`}
onClick={handleToggleAutoUpdate}
disabled={savingAutoUpdate}
style={{ flexShrink: 0 }}
>
{savingAutoUpdate
? 'Saving...'
: updateStatus.updater.config.autoUpdateEnabled
? 'Disable'
: 'Enable'}
</button>
</div>
)}
{!updateStatus?.updater.available && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--background)',
fontSize: '12px',
color: 'var(--foreground-secondary)',
}}>
One-click updates are unavailable on this host. Use:
<div style={{ marginTop: '8px', fontFamily: 'monospace', color: 'var(--foreground)' }}>
curl -fsSL https://synapsis.social/update.sh | bash
</div>
</div>
)}
</div>
</div>
)}
+1 -4
View File
@@ -51,10 +51,7 @@ export async function POST(request: Request) {
// First get conversation IDs where user is participant1 (local user)
// For participant2, we need to check by handle since it's stored as text (can be remote)
const conversations = await db.query.chatConversations.findMany({
where: or(
eq(chatConversations.participant1Id, userId),
eq(chatConversations.participant2Handle, user.handle)
),
where: { OR: [{ participant1Id: userId }, { participant2Handle: user.handle }] },
});
const conversationIds = conversations.map(c => c.id);
+1 -1
View File
@@ -48,7 +48,7 @@ export async function POST(request: Request) {
// Check if email is already taken by another user
const existingUser = await db.query.users.findFirst({
where: eq(users.email, newEmail.toLowerCase()),
where: { email: newEmail.toLowerCase() },
});
if (existingUser && existingUser.id !== user.id) {
+6 -6
View File
@@ -156,11 +156,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'This account has already been migrated' }, { status: 400 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
// Fetch user's posts
const userPosts = await db.query.posts.findMany({
where: eq(posts.userId, user.id),
where: { userId: user.id },
with: {
media: true,
},
@@ -169,19 +169,19 @@ export async function POST(req: NextRequest) {
// Fetch user's following list (local and remote)
const userFollowing = await db.query.follows.findMany({
where: eq(follows.followerId, user.id),
where: { followerId: user.id },
with: {
following: true,
},
});
const userRemoteFollowing = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.followerId, user.id),
where: { followerId: user.id },
});
// Fetch DMs
const userConversations = await db.query.chatConversations.findMany({
where: eq(chatConversations.participant1Id, user.id),
where: { participant1Id: user.id },
with: {
messages: true
}
@@ -189,7 +189,7 @@ export async function POST(req: NextRequest) {
// Fetch Bots
const userBots = await db.query.bots.findMany({
where: eq(bots.ownerId, user.id),
where: { ownerId: user.id },
with: {
user: true,
contentSources: true,
+5 -5
View File
@@ -203,7 +203,7 @@ export async function POST(req: NextRequest) {
// Check if DID already exists on this node
const existingDid = await db.query.users.findFirst({
where: eq(users.did, manifest.did),
where: { did: manifest.did },
});
if (existingDid) {
@@ -222,7 +222,7 @@ export async function POST(req: NextRequest) {
// Check if handle is available
const existingHandle = await db.query.users.findFirst({
where: eq(users.handle, handleClean),
where: { handle: handleClean },
});
if (existingHandle) {
@@ -232,7 +232,7 @@ export async function POST(req: NextRequest) {
}, { status: 409 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const oldActorUrl = `https://${manifest.sourceNode}/users/${manifest.handle}`;
const newActorUrl = `https://${nodeDomain}/users/${handleClean}`;
@@ -253,7 +253,7 @@ export async function POST(req: NextRequest) {
// Check if this is an NSFW node and auto-enable NSFW settings
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, nodeDomain),
where: { domain: nodeDomain },
});
if (node?.isNsfw) {
@@ -319,7 +319,7 @@ export async function POST(req: NextRequest) {
} else {
// Local follow - look up user and add to follows table
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, follow.handle.toLowerCase()),
where: { handle: follow.handle.toLowerCase() },
});
if (targetUser) {
await db.insert(follows).values({
+2 -2
View File
@@ -29,7 +29,7 @@ export async function POST(req: NextRequest) {
// Find the user on this node
const user = await db.query.users.findFirst({
where: eq(users.handle, oldHandle.toLowerCase()),
where: { handle: oldHandle.toLowerCase() },
});
if (!user) {
@@ -67,7 +67,7 @@ export async function POST(req: NextRequest) {
// Get all followers to notify
const userFollowers = await db.query.follows.findMany({
where: eq(follows.followingId, user.id),
where: { followingId: user.id },
with: {
follower: true,
},
+2 -2
View File
@@ -9,10 +9,10 @@ export async function PATCH(req: NextRequest) {
await requireAdmin();
const data = await req.json();
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
let node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
where: { domain: domain },
});
// 2. Fallback: If not found, check if there is exactly ONE node in the system.
+2 -2
View File
@@ -95,9 +95,9 @@ export async function POST(req: NextRequest) {
const dataUrl = `data:${mimeType};base64,${base64Data}`;
// Get current node
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
let node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
where: { domain: domain },
});
// Fallback: If not found, check if there is exactly ONE node in the system
+2 -2
View File
@@ -16,7 +16,7 @@ export async function GET() {
await requireAdmin();
const nodes = await db.query.swarmNodes.findMany({
orderBy: [desc(swarmNodes.isBlocked), desc(swarmNodes.blockedAt), desc(swarmNodes.lastSeenAt)],
orderBy: () => [desc(swarmNodes.isBlocked), desc(swarmNodes.blockedAt), desc(swarmNodes.lastSeenAt)],
});
return NextResponse.json({
@@ -47,7 +47,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json();
const data = mutateNodeSchema.parse(body);
const domain = normalizeNodeDomain(data.domain);
const localDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000');
const localDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821');
if (domain === localDomain) {
return NextResponse.json({ error: 'Cannot block this node itself' }, { status: 400 });
+1 -1
View File
@@ -24,7 +24,7 @@ export async function PATCH(request: Request, context: RouteContext) {
const data = moderationSchema.parse(body);
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
where: { id: id },
});
if (!post) {
+4 -7
View File
@@ -15,19 +15,16 @@ export async function GET(request: Request) {
const status = searchParams.get('status') || 'active'; // active | removed | all
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const where =
status === 'active'
? eq(posts.isRemoved, false)
: status === 'removed'
? eq(posts.isRemoved, true)
: undefined;
const where = status === 'all'
? undefined
: { isRemoved: status === 'removed' };
const results = await db.query.posts.findMany({
where,
with: {
author: true,
},
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit,
});
+1 -1
View File
@@ -24,7 +24,7 @@ export async function PATCH(request: Request, context: RouteContext) {
const data = updateSchema.parse(body);
const report = await db.query.reports.findFirst({
where: eq(reports.id, id),
where: { id: id },
});
if (!report) {
+4 -4
View File
@@ -16,8 +16,8 @@ export async function GET(request: Request) {
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const reportRows = await db.query.reports.findMany({
where: status === 'all' ? undefined : eq(reports.status, status),
orderBy: [desc(reports.createdAt)],
where: status === 'all' ? undefined : { status: status },
orderBy: () => [desc(reports.createdAt)],
limit,
with: {
reporter: true,
@@ -34,13 +34,13 @@ export async function GET(request: Request) {
const postTargetsRaw = postIds.length
? await db.query.posts.findMany({
where: inArray(posts.id, postIds),
where: { id: { in: postIds } },
with: { author: true },
})
: [];
const userTargetsRaw = userIds.length
? await db.query.users.findMany({
where: inArray(users.id, userIds),
where: { id: { in: userIds } },
})
: [];
-82
View File
@@ -1,82 +0,0 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth/admin';
import { getCurrentBuildInfo, getLatestPublishedBuild, compareBuildVersions } from '@/lib/version';
import { getHostUpdaterStatus, triggerHostUpdate, updateHostUpdaterConfig } from '@/lib/host-updater';
function isUpdateAvailable(currentVersion: string, latestVersion: string | null | undefined) {
if (!latestVersion) {
return false;
}
return compareBuildVersions(currentVersion, latestVersion) < 0;
}
export async function GET() {
try {
await requireAdmin();
const [latest, updater] = await Promise.all([
getLatestPublishedBuild(),
getHostUpdaterStatus(),
]);
const current = getCurrentBuildInfo();
return NextResponse.json({
current,
latest,
updateAvailable: isUpdateAvailable(current.version, latest?.version),
updater,
});
} catch (error) {
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('[Admin Update] Status error:', error);
return NextResponse.json({ error: 'Failed to get update status' }, { status: 500 });
}
}
export async function POST() {
try {
await requireAdmin();
const result = await triggerHostUpdate();
return NextResponse.json(result, { status: 202 });
} catch (error) {
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('[Admin Update] Trigger error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to trigger update' },
{ status: 500 }
);
}
}
export async function PATCH(request: Request) {
try {
await requireAdmin();
const body = await request.json();
if (typeof body.autoUpdateEnabled !== 'boolean') {
return NextResponse.json({ error: 'autoUpdateEnabled must be a boolean' }, { status: 400 });
}
const result = await updateHostUpdaterConfig(body.autoUpdateEnabled);
return NextResponse.json(result);
} catch (error) {
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('[Admin Update] Config error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to update auto-update settings' },
{ status: 500 }
);
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ export async function PATCH(request: Request, context: RouteContext) {
const data = moderationSchema.parse(body);
const user = await db.query.users.findFirst({
where: eq(users.id, id),
where: { id: id },
});
if (!user) {
+1 -1
View File
@@ -25,7 +25,7 @@ export async function GET(req: NextRequest) {
let existingUser = null;
try {
existingUser = await db.query.users.findFirst({
where: eq(users.handle, handle),
where: { handle: handle },
});
} catch (err: any) {
// Handle fresh installs where the users table isn't created yet.
+2 -2
View File
@@ -81,9 +81,9 @@ export async function POST(request: Request) {
);
// Check if this is an NSFW node and auto-enable NSFW settings
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, nodeDomain),
where: { domain: nodeDomain },
});
if (node?.isNsfw) {
+1 -1
View File
@@ -29,7 +29,7 @@ export async function GET(
// Verify bot exists and user owns it
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
where: { id: botId },
columns: { id: true, userId: true },
});
+1 -1
View File
@@ -29,7 +29,7 @@ export async function GET(
// Verify bot exists and user owns it
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
where: { id: botId },
columns: { id: true, userId: true },
});
@@ -38,7 +38,7 @@ export async function POST(
// Verify bot exists and user owns it
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
where: { id: botId },
columns: {
id: true,
userId: true,
+1 -1
View File
@@ -41,7 +41,7 @@ export async function GET(
// Verify bot exists and user owns it
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
where: { id: botId },
columns: {
id: true,
userId: true,
+1 -1
View File
@@ -31,7 +31,7 @@ export async function POST(
// Verify bot exists
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
where: { id: botId },
columns: { id: true, userId: true },
});
+1 -1
View File
@@ -41,7 +41,7 @@ export async function POST(
// Verify bot exists
const bot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
where: { id: botId },
columns: { id: true, userId: true },
});
+1 -1
View File
@@ -70,7 +70,7 @@ export async function POST(request: Request) {
let botAvatarUrl: string | null | undefined = data.avatarUrl;
if (!botAvatarUrl && storageSession) {
try {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const botHandle = `${data.handle.toLowerCase()}@${nodeDomain}`;
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
+4 -7
View File
@@ -133,7 +133,7 @@ export async function POST(request: NextRequest) {
// 1. Resolve Sender Public Key
let senderUser = await db.query.users.findFirst({
where: eq(users.did, did)
where: { did: did }
});
let publicKey = senderUser?.publicKey;
@@ -155,7 +155,7 @@ export async function POST(request: NextRequest) {
} else {
// Try handle registry (though we likely don't have it if we don't have the user)
const registryEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.did, did)
where: { did: did }
});
if (registryEntry) senderNodeDomain = normalizeNodeDomain(registryEntry.nodeDomain);
}
@@ -234,7 +234,7 @@ export async function POST(request: NextRequest) {
// 3. Find Local Recipient
const recipientUser = await db.query.users.findFirst({
where: eq(users.did, recipientDid)
where: { did: recipientDid }
});
if (!recipientUser) {
@@ -247,10 +247,7 @@ export async function POST(request: NextRequest) {
const computedFullSenderHandle = senderHandle.includes('@') ? senderHandle : (senderNodeDomain ? `${senderHandle}@${senderNodeDomain}` : senderHandle);
let conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, computedFullSenderHandle)
)
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: computedFullSenderHandle }] }
});
if (!conversation) {
+6 -18
View File
@@ -33,7 +33,7 @@ export async function POST(request: NextRequest) {
// Check if recipient is local
const recipientUser = await db.query.users.findFirst({
where: eq(users.did, recipientDid)
where: { did: recipientDid }
});
// Check if recipient is a local user (not remote/swarm cached)
@@ -55,10 +55,7 @@ export async function POST(request: NextRequest) {
} else if (recipientUser.dmPrivacy === 'following') {
// Check if recipient follows the sender
const isFollowingSender = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, recipientUser.id),
eq(follows.followingId, user.id)
)
where: { AND: [{ followerId: recipientUser.id }, { followingId: user.id }] }
});
if (!isFollowingSender) {
return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 });
@@ -69,10 +66,7 @@ export async function POST(request: NextRequest) {
// Ensure conversations exist for both sides
// 1. Recipient's Inbox (Recipient -> User)
let recipientConv = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, user.handle)
)
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: user.handle }] }
});
if (!recipientConv) {
@@ -96,10 +90,7 @@ export async function POST(request: NextRequest) {
// 2. Sender's Sent Box (User -> Recipient)
let senderConv = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, recipientUser.handle)
)
where: { AND: [{ participant1Id: user.id }, { participant2Handle: recipientUser.handle }] }
});
if (!senderConv) {
@@ -151,7 +142,7 @@ export async function POST(request: NextRequest) {
// 1. Resolve recipient node
const registryEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.did, recipientDid)
where: { did: recipientDid }
});
// If not in registry, try to parse from handle if it has domain
@@ -224,10 +215,7 @@ export async function POST(request: NextRequest) {
// 3. Store "Sent" copy locally
// Ensure conversation exists locally
let senderConv = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, recipientHandle)
)
where: { AND: [{ participant1Id: user.id }, { participant2Handle: recipientHandle }] }
});
if (!senderConv) {
+2 -5
View File
@@ -12,7 +12,7 @@ export async function GET() {
// Get user's conversations
const conversations = await db.query.chatConversations.findMany({
where: eq(chatConversations.participant1Id, session.user.id),
where: { participant1Id: session.user.id },
});
if (conversations.length === 0) {
@@ -23,10 +23,7 @@ export async function GET() {
const conversationIds = conversations.map(c => c.id);
const unreadMessages = await db.query.chatMessages.findMany({
where: and(
inArray(chatMessages.conversationId, conversationIds),
isNull(chatMessages.readAt)
),
where: { AND: [{ conversationId: { in: conversationIds } }, { readAt: { isNull: true } }] },
});
// Filter out messages sent by the current user
+1 -1
View File
@@ -2,6 +2,6 @@ import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN || 'localhost:3000',
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN || 'localhost:43821',
});
}
+4 -4
View File
@@ -21,14 +21,14 @@ export async function GET(req: NextRequest) {
try {
if (!db) {
// Redirect to default favicon
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const baseUrl = getRequestBaseUrl(req, domain);
return NextResponse.redirect(new URL('/favicon.png', baseUrl));
}
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
where: { domain: domain },
columns: { faviconUrl: true },
});
@@ -46,7 +46,7 @@ export async function GET(req: NextRequest) {
return NextResponse.redirect(new URL('/favicon.png', baseUrl));
} catch (error) {
console.error('Favicon error:', error);
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const baseUrl = getRequestBaseUrl(req, domain);
return NextResponse.redirect(new URL('/favicon.png', baseUrl));
}
+1 -1
View File
@@ -41,7 +41,7 @@ export async function GET(request: Request) {
const parsed = parseHandleWithDomain(handleParam);
const lookupHandle = parsed ? parsed.handle : normalizeHandle(handleParam);
const localEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.handle, lookupHandle),
where: { handle: lookupHandle },
});
if (localEntry) {
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
/**
* Health check endpoint for Docker and monitoring
* Health check endpoint for systemd and external monitoring
* Returns 200 OK when the application is running properly
*/
export async function GET() {
+2 -2
View File
@@ -10,11 +10,11 @@ export async function GET() {
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
}
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
// 1. Try exact match
let node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
where: { domain: domain },
});
// 2. Fallback: If not found, check if there is exactly ONE node in the system
+2 -2
View File
@@ -10,11 +10,11 @@ export async function GET() {
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
}
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
// 1. Try exact match
let node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
where: { domain: domain },
});
// 2. Fallback: If not found, check if there is exactly ONE node in the system
+2 -2
View File
@@ -9,11 +9,11 @@ export async function GET() {
try {
if (!db) return NextResponse.json({});
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
// 1. Try exact match
let node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
where: { domain: domain },
});
// 2. Fallback: If not found, check if there is exactly ONE node in the system.
+5 -7
View File
@@ -45,14 +45,12 @@ export async function GET(request: Request) {
const limit = Math.min(parseInt(searchParams.get('limit') || '30'), 50);
const unreadOnly = searchParams.get('unread') === 'true';
const conditions = [eq(notifications.userId, user.id)];
if (unreadOnly) {
conditions.push(isNull(notifications.readAt));
}
const rows = await db.query.notifications.findMany({
where: and(...conditions),
orderBy: [desc(notifications.createdAt)],
where: {
userId: user.id,
...(unreadOnly ? { readAt: { isNull: true as const } } : {}),
},
orderBy: () => [desc(notifications.createdAt)],
limit,
});
+8 -14
View File
@@ -120,7 +120,7 @@ export async function POST(request: Request, context: RouteContext) {
const decodedId = decodedParamId;
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -178,7 +178,7 @@ export async function POST(request: Request, context: RouteContext) {
// Local post - check if it exists
const post = await db.query.posts.findFirst({
where: eq(posts.id, postId),
where: { id: postId },
});
if (!post) {
@@ -190,10 +190,7 @@ export async function POST(request: Request, context: RouteContext) {
// Check if already liked
const existingLike = await db.query.likes.findFirst({
where: and(
eq(likes.userId, user.id),
eq(likes.postId, postId)
),
where: { AND: [{ userId: user.id }, { postId: postId }] },
});
if (existingLike) {
@@ -213,7 +210,7 @@ export async function POST(request: Request, context: RouteContext) {
if (post.userId !== user.id) {
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, post.userId),
where: { id: post.userId },
});
// Create notification with actor info stored directly
@@ -323,7 +320,7 @@ export async function DELETE(request: Request, context: RouteContext) {
const decodedId = decodedParamId;
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -368,7 +365,7 @@ export async function DELETE(request: Request, context: RouteContext) {
// Local post - check if it exists
const post = await db.query.posts.findFirst({
where: eq(posts.id, postId),
where: { id: postId },
});
if (!post) {
@@ -380,10 +377,7 @@ export async function DELETE(request: Request, context: RouteContext) {
// Find the like
const existingLike = await db.query.likes.findFirst({
where: and(
eq(likes.userId, user.id),
eq(likes.postId, postId)
),
where: { AND: [{ userId: user.id }, { postId: postId }] },
});
if (!existingLike) {
@@ -395,7 +389,7 @@ export async function DELETE(request: Request, context: RouteContext) {
// Update post's like count (atomic decrement, clamped to 0)
await db.update(posts)
.set({ likesCount: sql`GREATEST(0, ${posts.likesCount} - 1)` })
.set({ likesCount: sql`max(0, ${posts.likesCount} - 1)` })
.where(eq(posts.id, postId));
// SWARM-FIRST: Deliver unlike to swarm node
+12 -28
View File
@@ -90,7 +90,7 @@ export async function POST(request: Request, context: RouteContext) {
const { id: rawId } = await context.params;
const decodedId = decodeURIComponent(rawId);
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -106,11 +106,7 @@ export async function POST(request: Request, context: RouteContext) {
}
const existingRepost = await db.query.userSwarmReposts.findFirst({
where: and(
eq(userSwarmReposts.userId, user.id),
eq(userSwarmReposts.nodeDomain, targetDomain),
eq(userSwarmReposts.originalPostId, originalPostId),
),
where: { AND: [{ userId: user.id }, { nodeDomain: targetDomain }, { originalPostId: originalPostId }] },
});
if (existingRepost) {
@@ -165,7 +161,7 @@ export async function POST(request: Request, context: RouteContext) {
// Local post - check if it exists
const originalPost = await db.query.posts.findFirst({
where: eq(posts.id, postId),
where: { id: postId },
});
if (!originalPost) {
@@ -177,11 +173,7 @@ export async function POST(request: Request, context: RouteContext) {
// Check if already reposted by this user
const existingRepost = await db.query.posts.findFirst({
where: and(
eq(posts.userId, user.id),
eq(posts.repostOfId, postId),
eq(posts.isRemoved, false)
),
where: { AND: [{ userId: user.id }, { repostOfId: postId }, { isRemoved: false }] },
});
if (existingRepost) {
@@ -210,7 +202,7 @@ export async function POST(request: Request, context: RouteContext) {
if (originalPost.userId !== user.id) {
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, originalPost.userId),
where: { id: originalPost.userId },
});
// Create notification with actor info stored directly
@@ -300,7 +292,7 @@ export async function DELETE(request: Request, context: RouteContext) {
const { id: rawId } = await context.params;
const decodedId = decodeURIComponent(rawId);
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -316,11 +308,7 @@ export async function DELETE(request: Request, context: RouteContext) {
}
const existingRepost = await db.query.userSwarmReposts.findFirst({
where: and(
eq(userSwarmReposts.userId, user.id),
eq(userSwarmReposts.nodeDomain, targetDomain),
eq(userSwarmReposts.originalPostId, originalPostId),
),
where: { AND: [{ userId: user.id }, { nodeDomain: targetDomain }, { originalPostId: originalPostId }] },
});
if (!existingRepost) {
@@ -352,7 +340,7 @@ export async function DELETE(request: Request, context: RouteContext) {
));
await db.update(users)
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
.set({ postsCount: sql`max(0, ${users.postsCount} - 1)` })
.where(eq(users.id, user.id));
console.log(`[Swarm] Unrepost delivered to ${targetDomain} for post ${originalPostId}`);
@@ -361,16 +349,12 @@ export async function DELETE(request: Request, context: RouteContext) {
// Local post - check if original post exists
const originalPost = await db.query.posts.findFirst({
where: eq(posts.id, postId),
where: { id: postId },
});
// Find the repost by this user
const repost = await db.query.posts.findFirst({
where: and(
eq(posts.userId, user.id),
eq(posts.repostOfId, postId),
eq(posts.isRemoved, false)
),
where: { AND: [{ userId: user.id }, { repostOfId: postId }, { isRemoved: false }] },
});
if (!repost) {
@@ -385,13 +369,13 @@ export async function DELETE(request: Request, context: RouteContext) {
// Update original post's repost count
if (originalPost) {
await db.update(posts)
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
.set({ repostsCount: sql`max(0, ${posts.repostsCount} - 1)` })
.where(eq(posts.id, postId));
}
// Update user's post count
await db.update(users)
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
.set({ postsCount: sql`max(0, ${users.postsCount} - 1)` })
.where(eq(users.id, user.id));
return NextResponse.json({ success: true, reposted: false });
+12 -22
View File
@@ -88,7 +88,7 @@ export async function GET(
// Decode URL-encoded characters (e.g., %3A -> :)
const id = decodeURIComponent(rawId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
let mainPost: any = null;
let replyPosts: any[] = [];
@@ -168,7 +168,7 @@ export async function GET(
}
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
where: { id: id },
with: postDetailRelations,
});
@@ -176,12 +176,9 @@ export async function GET(
mainPost = post;
const replies = await db.query.posts.findMany({
where: and(
eq(posts.replyToId, id),
eq(posts.isRemoved, false)
),
where: { AND: [{ replyToId: id }, { isRemoved: false }] },
with: postDetailRelations,
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
});
mainPost = {
@@ -201,19 +198,12 @@ export async function GET(
if (allPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, allPostIds)
),
where: { AND: [{ userId: viewer.id }, { postId: { in: allPostIds } }] },
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, allPostIds),
eq(posts.isRemoved, false)
),
where: { AND: [{ userId: viewer.id }, { repostOfId: { in: allPostIds } }, { isRemoved: false }] },
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
@@ -233,7 +223,7 @@ export async function GET(
}
} else {
const cached = await db.query.remotePosts.findFirst({
where: eq(remotePosts.apId, id),
where: { apId: id },
});
if (cached) {
@@ -296,7 +286,7 @@ export async function DELETE(
const user = await requireAuth();
const { id } = await params;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
// Handle swarm post IDs (format: swarm:domain:uuid)
if (id.startsWith('swarm:')) {
@@ -393,7 +383,7 @@ export async function DELETE(
}
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
where: { id: id },
with: {
bot: true,
},
@@ -412,7 +402,7 @@ export async function DELETE(
let isParentPostOwner = false;
if (post.replyToId) {
const parentPost = await db.query.posts.findFirst({
where: eq(posts.id, post.replyToId),
where: { id: post.replyToId },
});
if (parentPost && parentPost.userId === user.id) {
isParentPostOwner = true;
@@ -426,7 +416,7 @@ export async function DELETE(
// 1. If it's a reply, decrement parent's repliesCount
if (post.replyToId) {
const parentPost = await db.query.posts.findFirst({
where: eq(posts.id, post.replyToId),
where: { id: post.replyToId },
});
if (parentPost && parentPost.repliesCount > 0) {
await db.update(posts)
@@ -472,7 +462,7 @@ export async function DELETE(
// 4. Decrement the post author's postsCount (atomic decrement, clamped to 0)
await db.update(users)
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
.set({ postsCount: sql`max(0, ${users.postsCount} - 1)` })
.where(eq(users.id, post.userId));
return NextResponse.json({ success: true });
+7 -7
View File
@@ -85,7 +85,7 @@ describe('POST /api/posts', () => {
};
// Create a mock request
const request = new Request('http://localhost:3000/api/posts', {
const request = new Request('http://localhost:43821/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -122,7 +122,7 @@ describe('POST /api/posts', () => {
signature: 'invalid-signature',
};
const request = new Request('http://localhost:3000/api/posts', {
const request = new Request('http://localhost:43821/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -152,7 +152,7 @@ describe('POST /api/posts', () => {
signature: 'test-signature',
};
const request = new Request('http://localhost:3000/api/posts', {
const request = new Request('http://localhost:43821/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -182,7 +182,7 @@ describe('POST /api/posts', () => {
signature: 'test-signature',
};
const request = new Request('http://localhost:3000/api/posts', {
const request = new Request('http://localhost:43821/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -212,7 +212,7 @@ describe('POST /api/posts', () => {
signature: 'test-signature',
};
const request = new Request('http://localhost:3000/api/posts', {
const request = new Request('http://localhost:43821/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -253,7 +253,7 @@ describe('POST /api/posts', () => {
signature: 'test-signature',
};
const request = new Request('http://localhost:3000/api/posts', {
const request = new Request('http://localhost:43821/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -293,7 +293,7 @@ describe('POST /api/posts', () => {
signature: 'test-signature',
};
const request = new Request('http://localhost:3000/api/posts', {
const request = new Request('http://localhost:43821/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+62 -71
View File
@@ -3,7 +3,6 @@ import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows,
import { requireAuth } from '@/lib/auth';
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm';
import type { SQL } from 'drizzle-orm';
import { z } from 'zod';
import { buildNotificationTarget } from '@/lib/notifications';
import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
@@ -13,12 +12,6 @@ const CURATION_WINDOW_HOURS = 72;
const CURATION_SEED_MULTIPLIER = 5;
const CURATION_SEED_CAP = 200;
const buildWhere = (...conditions: Array<SQL | undefined>) => {
const filtered = conditions.filter(Boolean) as SQL[];
if (filtered.length === 0) return undefined;
return and(...filtered);
};
type FeedPostWithChildren = {
id: string;
repostOf?: FeedPostWithChildren | null;
@@ -90,13 +83,13 @@ async function getMixedFeedCursorDate(cursor: string | null) {
if (cursor.startsWith('swarm-repost:')) {
const repostRow = await db.query.userSwarmReposts.findFirst({
where: eq(userSwarmReposts.id, cursor.replace('swarm-repost:', '')),
where: { id: cursor.replace('swarm-repost:', '') },
});
return repostRow?.repostedAt ?? null;
}
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
where: { id: cursor },
});
return cursorPost?.createdAt ?? null;
}
@@ -211,7 +204,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
// Build swarm reply fields if replying to a swarm post
const swarmReplyFields = data.swarmReplyTo ? {
@@ -245,11 +238,7 @@ export async function POST(request: Request) {
let unattachedMedia: typeof media.$inferSelect[] = [];
if (data.mediaIds?.length) {
unattachedMedia = await db.query.media.findMany({
where: and(
inArray(media.id, data.mediaIds),
eq(media.userId, user.id),
isNull(media.postId),
),
where: { AND: [{ id: { in: data.mediaIds } }, { userId: user.id }, { postId: { isNull: true } }] },
});
}
@@ -329,18 +318,14 @@ export async function POST(request: Request) {
let attachedMedia: typeof media.$inferSelect[] = [];
if (data.mediaIds?.length) {
attachedMedia = await db.query.media.findMany({
where: and(
inArray(media.id, data.mediaIds),
eq(media.userId, user.id),
eq(media.postId, post.id),
),
where: { AND: [{ id: { in: data.mediaIds } }, { userId: user.id }, { postId: post.id }] },
});
}
if (data.replyToId) {
try {
const parentPost = await db.query.posts.findFirst({
where: eq(posts.id, data.replyToId),
where: { id: data.replyToId },
with: {
author: true,
},
@@ -395,7 +380,7 @@ export async function POST(request: Request) {
// Find the mentioned user
const mentionedUser = await db.query.users.findFirst({
where: eq(users.handle, mention.handle.toLowerCase()),
where: { handle: mention.handle.toLowerCase() },
});
if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) {
@@ -612,38 +597,41 @@ export async function GET(request: Request) {
let feedPosts;
// Base filter excludes removed posts and replies (replies only show on detail/profile)
const baseFilter = buildWhere(
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
);
const baseFilter = {
isRemoved: false,
replyToId: { isNull: true as const },
swarmReplyToId: { isNull: true as const },
};
// Filter for replies only
const repliesFilter = buildWhere(
eq(posts.isRemoved, false),
or(
isNotNull(posts.replyToId),
isNotNull(posts.swarmReplyToId)
)
);
const repliesFilter = {
isRemoved: false,
OR: [
{ replyToId: { isNotNull: true as const } },
{ swarmReplyToId: { isNotNull: true as const } },
],
};
if (type === 'local') {
// Local node posts only
let whereCondition = baseFilter;
let whereCondition = {
...baseFilter,
createdAt: undefined as { lt: Date } | undefined,
};
// Apply cursor-based pagination
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
where: { id: cursor },
});
if (cursorPost) {
whereCondition = buildWhere(baseFilter, lt(posts.createdAt, cursorPost.createdAt));
whereCondition = { ...baseFilter, createdAt: { lt: cursorPost.createdAt } };
}
}
feedPosts = await db.query.posts.findMany({
where: whereCondition,
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit,
});
} else if (type === 'public') {
@@ -651,13 +639,13 @@ export async function GET(request: Request) {
const localPosts = await db.query.posts.findMany({
where: baseFilter,
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit: limit * 2,
});
// Get all cached remote posts
const remotePostsData = await db.query.remotePosts.findMany({
orderBy: [desc(remotePosts.publishedAt)],
orderBy: () => [desc(remotePosts.publishedAt)],
limit: limit,
});
@@ -669,42 +657,50 @@ export async function GET(request: Request) {
.slice(0, limit) as any;
} else if (type === 'user' && userId) {
// User's posts (excluding replies)
let whereCondition = buildWhere(baseFilter, eq(posts.userId, userId));
let whereCondition = {
...baseFilter,
userId,
createdAt: undefined as { lt: Date } | undefined,
};
// Apply cursor-based pagination
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
where: { id: cursor },
});
if (cursorPost) {
whereCondition = buildWhere(baseFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
whereCondition = { ...baseFilter, userId, createdAt: { lt: cursorPost.createdAt } };
}
}
feedPosts = await db.query.posts.findMany({
where: whereCondition,
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit,
});
} else if (type === 'replies' && userId) {
// User's replies only
let whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId));
let whereCondition = {
...repliesFilter,
userId,
createdAt: undefined as { lt: Date } | undefined,
};
// Apply cursor-based pagination
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
where: { id: cursor },
});
if (cursorPost) {
whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
whereCondition = { ...repliesFilter, userId, createdAt: { lt: cursorPost.createdAt } };
}
}
feedPosts = await db.query.posts.findMany({
where: whereCondition,
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit,
});
} else if (type === 'curated') {
@@ -857,36 +853,38 @@ export async function GET(request: Request) {
const allowedUserIds = [user.id, ...followingIds];
// Build where condition with cursor support
let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds));
let whereCondition = {
...baseFilter,
userId: { in: allowedUserIds },
createdAt: undefined as { lt: Date } | undefined,
};
const cursorDate = await getMixedFeedCursorDate(cursor);
if (cursorDate) {
whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorDate));
whereCondition = { ...baseFilter, userId: { in: allowedUserIds }, createdAt: { lt: cursorDate } };
}
// Get local posts from people the user follows + their own posts
const localPosts = await db.query.posts.findMany({
where: whereCondition,
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit: cursor ? limit : limit * 2, // Get more on first load to account for mixing with remote
});
const swarmRepostWhere = cursorDate
? and(
inArray(userSwarmReposts.userId, allowedUserIds),
lt(userSwarmReposts.repostedAt, cursorDate)
)
: inArray(userSwarmReposts.userId, allowedUserIds);
const swarmRepostWhere = {
userId: { in: allowedUserIds },
...(cursorDate ? { repostedAt: { lt: cursorDate } } : {}),
};
const swarmRepostRows = await db.query.userSwarmReposts.findMany({
where: swarmRepostWhere,
orderBy: [desc(userSwarmReposts.repostedAt)],
orderBy: () => [desc(userSwarmReposts.repostedAt)],
limit: cursor ? limit : limit * 2,
});
const swarmRepostAuthors = swarmRepostRows.length > 0
? await db.query.users.findMany({
where: inArray(users.id, Array.from(new Set(swarmRepostRows.map((row) => row.userId)))),
where: { id: { in: Array.from(new Set(swarmRepostRows.map((row) => row.userId))) } },
})
: [];
const swarmRepostAuthorMap = new Map(swarmRepostAuthors.map((author) => [author.id, author]));
@@ -902,7 +900,7 @@ export async function GET(request: Request) {
// Get handles of remote users we follow
const followedRemoteUsers = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.followerId, user.id),
where: { followerId: user.id },
});
// Fetch posts LIVE from followed remote users (in parallel, with timeout)
@@ -969,7 +967,7 @@ export async function GET(request: Request) {
feedPosts = await db.query.posts.findMany({
where: baseFilter,
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit,
});
}
@@ -982,7 +980,7 @@ export async function GET(request: Request) {
if (session?.user && feedPosts && feedPosts.length > 0) {
const viewer = session.user;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const allFeedPosts = collectNestedPosts(feedPosts as FeedPostWithChildren[]);
// Separate local and swarm posts
@@ -1010,19 +1008,12 @@ export async function GET(request: Request) {
if (localPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, localPostIds)
),
where: { AND: [{ userId: viewer.id }, { postId: { in: localPostIds } }] },
});
viewerLikes.forEach(l => likedPostIds.add(l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, localPostIds),
eq(posts.isRemoved, false)
),
where: { AND: [{ userId: viewer.id }, { repostOfId: { in: localPostIds } }, { isRemoved: false }] },
});
viewerReposts.forEach(r => { if (r.repostOfId) repostedPostIds.add(r.repostOfId); });
}
+1 -1
View File
@@ -79,7 +79,7 @@ export async function GET(request: NextRequest) {
const session = await getSession().catch(() => null);
const viewer = session?.user;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const allTimelinePosts = collectNestedSwarmPosts(timeline.posts as SwarmFeedPost[]);
const likedPostIds = viewer
? await getViewerSwarmLikedPostIds(
+2 -2
View File
@@ -21,7 +21,7 @@ export async function POST(request: Request) {
if (data.targetType === 'post') {
const targetPost = await db.query.posts.findFirst({
where: eq(posts.id, data.targetId),
where: { id: data.targetId },
});
if (!targetPost || targetPost.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
@@ -30,7 +30,7 @@ export async function POST(request: Request) {
if (data.targetType === 'user') {
const targetUser = await db.query.users.findFirst({
where: eq(users.id, data.targetId),
where: { id: data.targetId },
});
if (!targetUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
+12 -22
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { db, users, posts, likes } from '@/db';
import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm';
import { like, or, desc, and, eq, inArray } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
@@ -119,9 +119,9 @@ export async function GET(request: Request) {
if (searchUsers.length === 0) {
const userConditions = and(
or(
ilike(users.handle, searchPattern),
ilike(users.displayName, searchPattern),
ilike(users.bio, searchPattern)
like(users.handle, searchPattern),
like(users.displayName, searchPattern),
like(users.bio, searchPattern)
),
eq(users.isSuspended, false),
eq(users.isSilenced, false)
@@ -187,17 +187,14 @@ export async function GET(request: Request) {
// Search posts
if (type === 'all' || type === 'posts') {
const postConditions = [
ilike(posts.content, searchPattern),
eq(posts.isRemoved, false),
];
if (moderatedIds.length) {
postConditions.push(notInArray(posts.userId, moderatedIds));
}
const postResults = await db.query.posts.findMany({
where: and(...postConditions),
where: {
content: { like: searchPattern },
isRemoved: false,
...(moderatedIds.length ? { userId: { notIn: moderatedIds } } : {}),
},
with: searchPostRelations,
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit,
});
searchPosts = postResults;
@@ -213,19 +210,12 @@ export async function GET(request: Request) {
if (postIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds)
),
where: { AND: [{ userId: viewer.id }, { postId: { in: postIds } }] },
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds),
eq(posts.isRemoved, false)
),
where: { AND: [{ userId: viewer.id }, { repostOfId: { in: postIds } }, { isRemoved: false }] },
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
+1 -1
View File
@@ -10,7 +10,7 @@ export async function GET() {
const currentUser = await requireAuth();
const blocked = await db.query.blocks.findMany({
where: eq(blocks.userId, currentUser.id),
where: { userId: currentUser.id },
with: {
blockedUser: true,
},
+2 -5
View File
@@ -10,7 +10,7 @@ export async function GET() {
const currentUser = await requireAuth();
const muted = await db.query.mutedNodes.findMany({
where: eq(mutedNodes.userId, currentUser.id),
where: { userId: currentUser.id },
orderBy: (t, { desc }) => [desc(t.createdAt)],
});
@@ -43,10 +43,7 @@ export async function POST(req: NextRequest) {
// Check if already muted
const existing = await db.query.mutedNodes.findFirst({
where: and(
eq(mutedNodes.userId, currentUser.id),
eq(mutedNodes.nodeDomain, normalizedDomain)
),
where: { AND: [{ userId: currentUser.id }, { nodeDomain: normalizedDomain }] },
});
if (existing) {
@@ -54,10 +54,7 @@ export async function DELETE(
// Verify the conversation belongs to this user
const conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.id, id),
eq(chatConversations.participant1Id, session.user.id)
),
where: { AND: [{ id: id }, { participant1Id: session.user.id }] },
});
if (!conversation) {
@@ -113,16 +110,13 @@ export async function DELETE(
} else {
// Local user - find and delete their conversation too
const recipientUser = await db.query.users.findFirst({
where: eq(users.handle, participant2Handle),
where: { handle: participant2Handle },
});
if (recipientUser) {
// Find their conversation with us
const recipientConversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, session.user.handle)
),
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] },
});
if (recipientConversation) {
@@ -22,11 +22,11 @@ export async function GET(request: NextRequest) {
// Get all conversations for this user
const conversations = await db.query.chatConversations.findMany({
where: eq(chatConversations.participant1Id, session.user.id),
orderBy: [desc(chatConversations.lastMessageAt)],
where: { participant1Id: session.user.id },
orderBy: () => [desc(chatConversations.lastMessageAt)],
with: {
messages: {
orderBy: [desc(chatMessages.createdAt)],
orderBy: () => [desc(chatMessages.createdAt)],
limit: 1,
},
},
@@ -59,7 +59,7 @@ export async function GET(request: NextRequest) {
// Try to get cached user info
let cachedUser = await db.query.users.findFirst({
where: eq(users.handle, participant2Handle),
where: { handle: participant2Handle },
});
// If not found, check if it's a local user with a domain suffix
@@ -67,7 +67,7 @@ export async function GET(request: NextRequest) {
const [handlePart, domainPart] = participant2Handle.split('@');
if (!domainPart || domainPart === process.env.NEXT_PUBLIC_NODE_DOMAIN) {
cachedUser = await db.query.users.findFirst({
where: eq(users.handle, handlePart),
where: { handle: handlePart },
});
}
}
@@ -91,7 +91,7 @@ export async function GET(request: NextRequest) {
// Re-query to get the new cached user
cachedUser = await db.query.users.findFirst({
where: eq(users.handle, participant2Handle),
where: { handle: participant2Handle },
}) as any;
}
} catch (e) {
+2 -5
View File
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
// Find the recipient (local user)
const recipient = await db.query.users.findFirst({
where: eq(users.handle, data.recipientHandle.toLowerCase()),
where: { handle: data.recipientHandle.toLowerCase() },
});
if (!recipient) {
@@ -63,10 +63,7 @@ export async function POST(request: NextRequest) {
// Find the conversation with the sender
const senderFullHandle = `${data.senderHandle}@${senderNodeDomain}`;
const conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipient.id),
eq(chatConversations.participant2Handle, senderFullHandle)
),
where: { AND: [{ participant1Id: recipient.id }, { participant2Handle: senderFullHandle }] },
});
if (!conversation) {
+7 -14
View File
@@ -52,10 +52,7 @@ export async function GET(request: NextRequest) {
// Verify user has access to this conversation
const conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.id, conversationId),
eq(chatConversations.participant1Id, session.user.id)
),
where: { AND: [{ id: conversationId }, { participant1Id: session.user.id }] },
});
if (!conversation) {
@@ -63,15 +60,14 @@ export async function GET(request: NextRequest) {
}
// Build query with cursor-based pagination
const baseCondition = eq(chatMessages.conversationId, conversationId);
const whereCondition = cursor
? and(baseCondition, lt(chatMessages.createdAt, new Date(cursor)))!
: baseCondition;
? { conversationId, createdAt: { lt: new Date(cursor) } }
: { conversationId };
// Get messages
const messages = await db.query.chatMessages.findMany({
where: whereCondition,
orderBy: [desc(chatMessages.createdAt)],
orderBy: () => [desc(chatMessages.createdAt)],
limit,
});
@@ -92,7 +88,7 @@ export async function GET(request: NextRequest) {
if (senderDids.size > 0) {
const found = await db.query.users.findMany({
where: inArray(users.did, Array.from(senderDids))
where: { did: { in: Array.from(senderDids) } }
});
found.forEach(u => usersByDid[u.did] = u);
}
@@ -100,7 +96,7 @@ export async function GET(request: NextRequest) {
// Also fetch local users by handle if needed
if (senderHandles.size > 0) {
const found = await db.query.users.findMany({
where: inArray(users.handle, Array.from(senderHandles))
where: { handle: { in: Array.from(senderHandles) } }
});
found.forEach(u => usersByHandle[u.handle] = u);
}
@@ -164,10 +160,7 @@ export async function PATCH(request: NextRequest) {
// Verify user has access to this conversation
const conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.id, conversationId),
eq(chatConversations.participant1Id, session.user.id)
),
where: { AND: [{ id: conversationId }, { participant1Id: session.user.id }] },
});
if (!conversation) {
@@ -56,7 +56,7 @@ export async function POST(request: NextRequest) {
// Find the target user (local user being followed)
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, data.targetHandle.toLowerCase()),
where: { handle: data.targetHandle.toLowerCase() },
});
if (!targetUser) {
@@ -73,10 +73,7 @@ export async function POST(request: NextRequest) {
// Check if this follow already exists
const existingFollow = await db.query.remoteFollowers.findFirst({
where: and(
eq(remoteFollowers.userId, targetUser.id),
eq(remoteFollowers.actorUrl, actorUrl)
),
where: { AND: [{ userId: targetUser.id }, { actorUrl: actorUrl }] },
});
if (existingFollow) {
+2 -6
View File
@@ -52,7 +52,7 @@ export async function POST(request: NextRequest) {
// Find the target post
const post = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
where: { id: data.postId },
with: { author: true },
});
@@ -66,11 +66,7 @@ export async function POST(request: NextRequest) {
// Check if already liked by this remote user
const existingLike = await db.query.remoteLikes.findFirst({
where: and(
eq(remoteLikes.postId, data.postId),
eq(remoteLikes.actorHandle, data.like.actorHandle),
eq(remoteLikes.actorNodeDomain, data.like.actorNodeDomain)
),
where: { AND: [{ postId: data.postId }, { actorHandle: data.like.actorHandle }, { actorNodeDomain: data.like.actorNodeDomain }] },
});
if (existingLike) {
@@ -54,7 +54,7 @@ export async function POST(request: NextRequest) {
// Find the mentioned user (local user)
const mentionedUser = await db.query.users.findFirst({
where: eq(users.handle, data.mentionedHandle.toLowerCase()),
where: { handle: data.mentionedHandle.toLowerCase() },
});
if (!mentionedUser) {
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
// Find the target post
const post = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
where: { id: data.postId },
with: { author: true },
});
@@ -66,11 +66,7 @@ export async function POST(request: NextRequest) {
}
const existingRepost = await db.query.remoteReposts.findFirst({
where: and(
eq(remoteReposts.postId, data.postId),
eq(remoteReposts.actorHandle, data.repost.actorHandle),
eq(remoteReposts.actorNodeDomain, data.repost.actorNodeDomain),
),
where: { AND: [{ postId: data.postId }, { actorHandle: data.repost.actorHandle }, { actorNodeDomain: data.repost.actorNodeDomain }] },
});
if (existingRepost) {
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
// Find the target user
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, data.targetHandle.toLowerCase()),
where: { handle: data.targetHandle.toLowerCase() },
});
if (!targetUser) {
@@ -60,10 +60,7 @@ export async function POST(request: NextRequest) {
const actorUrl = `swarm://${data.unfollow.followerNodeDomain}/${data.unfollow.followerHandle}`;
const existingFollow = await db.query.remoteFollowers.findFirst({
where: and(
eq(remoteFollowers.userId, targetUser.id),
eq(remoteFollowers.actorUrl, actorUrl)
),
where: { AND: [{ userId: targetUser.id }, { actorUrl: actorUrl }] },
});
if (!existingFollow) {
@@ -78,7 +75,7 @@ export async function POST(request: NextRequest) {
// Update follower count
await db.update(users)
.set({ followersCount: sql`GREATEST(0, ${users.followersCount} - 1)` })
.set({ followersCount: sql`max(0, ${users.followersCount} - 1)` })
.where(eq(users.id, targetUser.id));
console.log(`[Swarm] Received unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain} for @${data.targetHandle}`);
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
// Find the target post
const post = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
where: { id: data.postId },
});
if (!post) {
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
// Find the target post
const post = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
where: { id: data.postId },
});
if (!post) {
@@ -61,11 +61,7 @@ export async function POST(request: NextRequest) {
}
const existingRepost = await db.query.remoteReposts.findFirst({
where: and(
eq(remoteReposts.postId, data.postId),
eq(remoteReposts.actorHandle, data.unrepost.actorHandle),
eq(remoteReposts.actorNodeDomain, data.unrepost.actorNodeDomain),
),
where: { AND: [{ postId: data.postId }, { actorHandle: data.unrepost.actorHandle }, { actorNodeDomain: data.unrepost.actorNodeDomain }] },
});
if (!existingRepost) {
@@ -77,7 +73,7 @@ export async function POST(request: NextRequest) {
// Decrement repost count
await db.update(posts)
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
.set({ repostsCount: sql`max(0, ${posts.repostsCount} - 1)` })
.where(eq(posts.id, data.postId));
await db.delete(remoteReposts).where(eq(remoteReposts.id, existingRepost.id));
+4 -11
View File
@@ -59,7 +59,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
// Find the post
const post = await db.query.posts.findFirst({
where: eq(posts.id, postId),
where: { id: postId },
});
if (!post || post.isRemoved) {
@@ -71,11 +71,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
// If domain is provided, check remote likes
if (checkDomain) {
const remoteLike = await db.query.remoteLikes.findFirst({
where: and(
eq(remoteLikes.postId, postId),
eq(remoteLikes.actorHandle, checkHandle),
eq(remoteLikes.actorNodeDomain, checkDomain)
),
where: { AND: [{ postId: postId }, { actorHandle: checkHandle }, { actorNodeDomain: checkDomain }] },
});
return NextResponse.json({
@@ -89,15 +85,12 @@ export async function GET(request: NextRequest, context: RouteContext) {
// No domain = local user
const localUser = await db.query.users.findFirst({
where: eq(users.handle, checkHandle),
where: { handle: checkHandle },
});
if (localUser) {
const liked = await db.query.likes.findFirst({
where: and(
eq(likes.postId, postId),
eq(likes.userId, localUser.id)
),
where: { AND: [{ postId: postId }, { userId: localUser.id }] },
});
return NextResponse.json({
+5 -8
View File
@@ -36,7 +36,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
// Find the post
const post = await db.query.posts.findFirst({
where: eq(posts.id, postId),
where: { id: postId },
with: {
author: true,
media: true,
@@ -45,7 +45,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
if (!post || post.isRemoved) {
const remoteRepost = await db.query.userSwarmReposts.findFirst({
where: eq(userSwarmReposts.id, postId),
where: { id: postId },
});
if (!remoteRepost) {
@@ -53,7 +53,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
}
const repostAuthor = await db.query.users.findFirst({
where: eq(users.id, remoteRepost.userId),
where: { id: remoteRepost.userId },
});
return NextResponse.json({
@@ -104,15 +104,12 @@ export async function GET(request: NextRequest, context: RouteContext) {
// Get replies
const replies = await db.query.posts.findMany({
where: and(
eq(posts.replyToId, postId),
eq(posts.isRemoved, false)
),
where: { AND: [{ replyToId: postId }, { isRemoved: false }] },
with: {
author: true,
media: true,
},
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit: 50,
});
+5 -8
View File
@@ -34,7 +34,7 @@ const swarmReplySchema = z.object({
async function syncParentReplyCount(postId: string) {
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.select({ count: sql<number>`count(*)` })
.from(posts)
.where(and(
eq(posts.replyToId, postId),
@@ -82,10 +82,7 @@ export async function POST(request: NextRequest) {
}
const parentPost = await db.query.posts.findFirst({
where: and(
eq(posts.id, data.postId),
eq(posts.isRemoved, false)
),
where: { AND: [{ id: data.postId }, { isRemoved: false }] },
with: {
author: true,
},
@@ -107,7 +104,7 @@ export async function POST(request: NextRequest) {
});
const remoteUser = await db.query.users.findFirst({
where: eq(users.handle, remoteHandle),
where: { handle: remoteHandle },
});
if (!remoteUser) {
@@ -116,7 +113,7 @@ export async function POST(request: NextRequest) {
const replyApId = `swarm:${sourceDomain}:${data.reply.id}`;
const existingReply = await db.query.posts.findFirst({
where: eq(posts.apId, replyApId),
where: { apId: replyApId },
});
if (existingReply) {
@@ -205,7 +202,7 @@ export async function DELETE(request: NextRequest) {
// Find the reply by its swarm ID
const swarmReplyId = `swarm:${nodeDomain}:${replyId}`;
const existingReply = await db.query.posts.findFirst({
where: eq(posts.apId, swarmReplyId),
where: { apId: swarmReplyId },
});
if (!existingReply) {
+1 -1
View File
@@ -128,7 +128,7 @@ export async function GET(request: NextRequest) {
// Get node NSFW status
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, nodeDomain),
where: { domain: nodeDomain },
});
const nodeIsNsfw = node?.isNsfw ?? false;
@@ -41,7 +41,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
});
if (!user) {
@@ -74,7 +74,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
// Get remote followers
const userRemoteFollowers = await db.query.remoteFollowers.findMany({
where: eq(remoteFollowers.userId, user.id),
where: { userId: user.id },
limit,
});
@@ -40,7 +40,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
});
if (!user) {
@@ -73,7 +73,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
// Get remote following
const userRemoteFollowing = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.followerId, user.id),
where: { followerId: user.id },
limit,
});
+5 -10
View File
@@ -188,7 +188,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
with: {
botOwner: true, // Include bot owner if this is a bot
},
@@ -222,20 +222,15 @@ export async function GET(request: NextRequest, context: RouteContext) {
};
const localPosts = await db.query.posts.findMany({
where: and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
),
where: { AND: [{ userId: user.id }, { isRemoved: false }, { replyToId: { isNull: true } }, { swarmReplyToId: { isNull: true } }] },
with: profilePostRelations,
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit: limit * 2,
});
const remoteRepostRows = await db.query.userSwarmReposts.findMany({
where: eq(userSwarmReposts.userId, user.id),
orderBy: [desc(userSwarmReposts.repostedAt)],
where: { userId: user.id },
orderBy: () => [desc(userSwarmReposts.repostedAt)],
limit: limit * 2,
});
+5 -11
View File
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest, context: RouteContext) {
const { handle } = await context.params;
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, handle),
where: { handle: handle },
});
if (!targetUser) {
@@ -21,10 +21,7 @@ export async function GET(req: NextRequest, context: RouteContext) {
}
const block = await db.query.blocks.findFirst({
where: and(
eq(blocks.userId, currentUser.id),
eq(blocks.blockedUserId, targetUser.id)
),
where: { AND: [{ userId: currentUser.id }, { blockedUserId: targetUser.id }] },
});
return NextResponse.json({ blocked: !!block });
@@ -44,7 +41,7 @@ export async function POST(req: NextRequest, context: RouteContext) {
const { handle } = await context.params;
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, handle),
where: { handle: handle },
});
if (!targetUser) {
@@ -57,10 +54,7 @@ export async function POST(req: NextRequest, context: RouteContext) {
// Check if already blocked
const existing = await db.query.blocks.findFirst({
where: and(
eq(blocks.userId, currentUser.id),
eq(blocks.blockedUserId, targetUser.id)
),
where: { AND: [{ userId: currentUser.id }, { blockedUserId: targetUser.id }] },
});
if (existing) {
@@ -104,7 +98,7 @@ export async function DELETE(req: NextRequest, context: RouteContext) {
const { handle } = await context.params;
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, handle),
where: { handle: handle },
});
if (!targetUser) {
+14 -32
View File
@@ -37,10 +37,7 @@ export async function GET(request: Request, context: RouteContext) {
}
const targetHandle = `${remote.handle}@${remote.domain}`;
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
where: and(
eq(remoteFollows.followerId, currentUser.id),
eq(remoteFollows.targetHandle, targetHandle)
),
where: { AND: [{ followerId: currentUser.id }, { targetHandle: targetHandle }] },
});
return NextResponse.json({ following: !!existingRemoteFollow, remote: true });
}
@@ -50,7 +47,7 @@ export async function GET(request: Request, context: RouteContext) {
}
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
});
if (!targetUser) {
@@ -65,10 +62,7 @@ export async function GET(request: Request, context: RouteContext) {
}
const existingFollow = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, currentUser.id),
eq(follows.followingId, targetUser.id)
),
where: { AND: [{ followerId: currentUser.id }, { followingId: targetUser.id }] },
});
return NextResponse.json({ following: !!existingFollow });
@@ -96,7 +90,7 @@ export async function POST(request: Request, context: RouteContext) {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const remote = parseRemoteHandle(handle);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
if (currentUser.isSuspended || currentUser.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -107,10 +101,7 @@ export async function POST(request: Request, context: RouteContext) {
// Check if already following
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
where: and(
eq(remoteFollows.followerId, currentUser.id),
eq(remoteFollows.targetHandle, targetHandle)
),
where: { AND: [{ followerId: currentUser.id }, { targetHandle: targetHandle }] },
});
if (existingRemoteFollow) {
return NextResponse.json({ error: 'Already following' }, { status: 400 });
@@ -179,7 +170,7 @@ export async function POST(request: Request, context: RouteContext) {
// Find target user
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
});
if (!targetUser) {
@@ -196,10 +187,7 @@ export async function POST(request: Request, context: RouteContext) {
// Check if already following
const existingFollow = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, currentUser.id),
eq(follows.followingId, targetUser.id)
),
where: { AND: [{ followerId: currentUser.id }, { followingId: targetUser.id }] },
});
if (existingFollow) {
@@ -268,7 +256,7 @@ export async function DELETE(request: Request, context: RouteContext) {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const remote = parseRemoteHandle(handle);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
if (remote) {
if (!db) {
@@ -276,10 +264,7 @@ export async function DELETE(request: Request, context: RouteContext) {
}
const targetHandle = `${remote.handle}@${remote.domain}`;
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
where: and(
eq(remoteFollows.followerId, currentUser.id),
eq(remoteFollows.targetHandle, targetHandle)
),
where: { AND: [{ followerId: currentUser.id }, { targetHandle: targetHandle }] },
});
if (!existingRemoteFollow) {
return NextResponse.json({ error: 'Not following' }, { status: 400 });
@@ -306,7 +291,7 @@ export async function DELETE(request: Request, context: RouteContext) {
// Update the user's following count (atomic decrement, clamped to 0)
await db.update(users)
.set({ followingCount: sql`GREATEST(0, ${users.followingCount} - 1)` })
.set({ followingCount: sql`max(0, ${users.followingCount} - 1)` })
.where(eq(users.id, currentUser.id));
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
@@ -319,7 +304,7 @@ export async function DELETE(request: Request, context: RouteContext) {
// Find target user
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
});
if (!targetUser) {
@@ -331,10 +316,7 @@ export async function DELETE(request: Request, context: RouteContext) {
// Find existing follow
const existingFollow = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, currentUser.id),
eq(follows.followingId, targetUser.id)
),
where: { AND: [{ followerId: currentUser.id }, { followingId: targetUser.id }] },
});
if (!existingFollow) {
@@ -346,11 +328,11 @@ export async function DELETE(request: Request, context: RouteContext) {
// Update counts (atomic decrements, clamped to 0)
await db.update(users)
.set({ followingCount: sql`GREATEST(0, ${users.followingCount} - 1)` })
.set({ followingCount: sql`max(0, ${users.followingCount} - 1)` })
.where(eq(users.id, currentUser.id));
await db.update(users)
.set({ followersCount: sql`GREATEST(0, ${users.followersCount} - 1)` })
.set({ followersCount: sql`max(0, ${users.followersCount} - 1)` })
.where(eq(users.id, targetUser.id));
return NextResponse.json({ success: true, following: false });
@@ -61,7 +61,7 @@ export async function GET(request: Request, context: RouteContext) {
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
});
if (!user) {
@@ -94,7 +94,7 @@ export async function GET(request: Request, context: RouteContext) {
// Get remote followers
const userRemoteFollowers = await db.query.remoteFollowers.findMany({
where: eq(remoteFollowers.userId, user.id),
where: { userId: user.id },
limit,
});
@@ -61,7 +61,7 @@ export async function GET(request: Request, context: RouteContext) {
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
});
if (!user) {
@@ -94,7 +94,7 @@ export async function GET(request: Request, context: RouteContext) {
// Get remote following
const userRemoteFollowing = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.followerId, user.id),
where: { followerId: user.id },
limit,
});
+7 -14
View File
@@ -108,7 +108,7 @@ export async function GET(request: Request, context: RouteContext) {
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
});
const isRemotePlaceholder = user && cleanHandle.includes('@');
@@ -141,13 +141,13 @@ export async function GET(request: Request, context: RouteContext) {
// Get user's liked posts
const userLikes = await db.query.likes.findMany({
where: eq(likes.userId, user.id),
where: { userId: user.id },
with: {
post: {
with: likedPostRelations,
},
},
orderBy: [desc(likes.createdAt)],
orderBy: () => [desc(likes.createdAt)],
limit,
});
@@ -156,8 +156,8 @@ export async function GET(request: Request, context: RouteContext) {
.map(like => like.post);
const swarmLikedRows = await db.query.userSwarmLikes.findMany({
where: eq(userSwarmLikes.userId, user.id),
orderBy: [desc(userSwarmLikes.likedAt)],
where: { userId: user.id },
orderBy: () => [desc(userSwarmLikes.likedAt)],
limit,
});
@@ -225,19 +225,12 @@ export async function GET(request: Request, context: RouteContext) {
if (localPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, localPostIds)
),
where: { AND: [{ userId: viewer.id }, { postId: { in: localPostIds } }] },
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, localPostIds),
eq(posts.isRemoved, false)
),
where: { AND: [{ userId: viewer.id }, { repostOfId: { in: localPostIds } }, { isRemoved: false }] },
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
+20 -38
View File
@@ -151,13 +151,13 @@ async function getMixedProfileCursorDate(cursor: string | null) {
if (cursor.startsWith('swarm-repost:')) {
const repostRow = await db.query.userSwarmReposts.findFirst({
where: eq(userSwarmReposts.id, cursor.replace('swarm-repost:', '')),
where: { id: cursor.replace('swarm-repost:', '') },
});
return repostRow?.repostedAt ?? null;
}
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
where: { id: cursor },
});
return cursorPost?.createdAt ?? null;
}
@@ -173,7 +173,7 @@ async function populateViewerLikeState(
const { getSession } = await import('@/lib/auth');
const session = await getSession();
const viewer = session?.user;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
if (!viewer) {
return remotePosts;
@@ -301,7 +301,7 @@ export async function GET(request: Request, context: RouteContext) {
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
});
const isRemotePlaceholder = user && cleanHandle.includes('@');
@@ -330,39 +330,28 @@ export async function GET(request: Request, context: RouteContext) {
// Get user's posts with cursor-based pagination
const cursorDate = await getMixedProfileCursorDate(cursor);
let whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
);
if (cursorDate) {
whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId),
lt(posts.createdAt, cursorDate)
);
}
const whereConditions = {
userId: user.id,
isRemoved: false,
replyToId: { isNull: true as const },
swarmReplyToId: { isNull: true as const },
...(cursorDate ? { createdAt: { lt: cursorDate } } : {}),
};
const localPosts = await db.query.posts.findMany({
where: whereConditions,
with: userPostRelations,
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit: cursor ? limit : limit * 2,
});
const swarmRepostWhere = cursorDate
? and(
eq(userSwarmReposts.userId, user.id),
lt(userSwarmReposts.repostedAt, cursorDate)
)
: eq(userSwarmReposts.userId, user.id);
const swarmRepostWhere = {
userId: user.id,
...(cursorDate ? { repostedAt: { lt: cursorDate } } : {}),
};
const swarmRepostRows = await db.query.userSwarmReposts.findMany({
where: swarmRepostWhere,
orderBy: [desc(userSwarmReposts.repostedAt)],
orderBy: () => [desc(userSwarmReposts.repostedAt)],
limit: cursor ? limit : limit * 2,
});
let userPosts: any[] = [
@@ -400,19 +389,12 @@ export async function GET(request: Request, context: RouteContext) {
if (localPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, localPostIds)
),
where: { AND: [{ userId: viewer.id }, { postId: { in: localPostIds } }] },
});
viewerLikes.forEach((like) => likedPostIds.add(like.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, localPostIds),
eq(posts.isRemoved, false)
),
where: { AND: [{ userId: viewer.id }, { repostOfId: { in: localPostIds } }, { isRemoved: false }] },
});
viewerReposts.forEach((repost) => {
if (repost.repostOfId) {
@@ -422,7 +404,7 @@ export async function GET(request: Request, context: RouteContext) {
}
if (swarmTargets.length > 0) {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const likedIds = await getViewerSwarmLikedPostIds(
swarmTargets.map((post) => ({
id: post.id,
+16 -24
View File
@@ -95,7 +95,7 @@ export async function GET(request: Request, context: RouteContext) {
}
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
});
const isRemotePlaceholder = user && cleanHandle.includes('@');
@@ -121,30 +121,29 @@ export async function GET(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
let whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)),
);
let cursorDate: Date | undefined;
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
where: { id: cursor },
});
if (cursorPost) {
whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)),
lt(posts.createdAt, cursorPost.createdAt),
);
cursorDate = cursorPost.createdAt;
}
}
let replyPosts: any[] = await db.query.posts.findMany({
where: whereConditions,
where: {
userId: user.id,
isRemoved: false,
OR: [
{ replyToId: { isNotNull: true } },
{ swarmReplyToId: { isNotNull: true } },
],
...(cursorDate ? { createdAt: { lt: cursorDate } } : {}),
},
with: replyRelations,
orderBy: [desc(posts.createdAt)],
orderBy: () => [desc(posts.createdAt)],
limit,
});
@@ -158,21 +157,14 @@ export async function GET(request: Request, context: RouteContext) {
const viewerLikes = postIds.length > 0
? await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds),
),
where: { AND: [{ userId: viewer.id }, { postId: { in: postIds } }] },
})
: [];
const likedPostIds = new Set(viewerLikes.map((like) => like.postId));
const viewerReposts = postIds.length > 0
? await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds),
eq(posts.isRemoved, false),
),
where: { AND: [{ userId: viewer.id }, { repostOfId: { in: postIds } }, { isRemoved: false }] },
})
: [];
const repostedPostIds = new Set(viewerReposts.map((post) => post.repostOfId));
+3 -6
View File
@@ -32,7 +32,7 @@ export async function GET(request: Request, context: RouteContext) {
}
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
where: { handle: cleanHandle },
});
// If user exists but is a remote placeholder (handle contains @), fetch fresh data from remote
@@ -147,10 +147,7 @@ export async function GET(request: Request, context: RouteContext) {
canReceiveDms = true; // Can DM yourself
} else {
const isFollowingViewer = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, user.id),
eq(follows.followingId, session.user.id)
)
where: { AND: [{ followerId: user.id }, { followingId: session.user.id }] }
});
if (isFollowingViewer) {
canReceiveDms = true;
@@ -163,7 +160,7 @@ export async function GET(request: Request, context: RouteContext) {
// If this is a bot, include owner info
if (user.isBot && user.botOwnerId) {
const owner = await db.query.users.findFirst({
where: eq(users.id, user.botOwnerId),
where: { id: user.botOwnerId },
});
if (owner) {
userResponse.botOwner = {