feat:friends
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { find, findMedia, user, findLike } from '$lib/server/db/schema';
|
||||
import { eq, and, sql, desc } from 'drizzle-orm';
|
||||
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
||||
import { eq, and, sql, desc, or } from 'drizzle-orm';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
|
||||
@@ -22,11 +22,47 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const includePrivate = url.searchParams.get('includePrivate') === 'true';
|
||||
const order = url.searchParams.get('order') || 'desc';
|
||||
|
||||
const includeFriends = url.searchParams.get('includeFriends') === 'true';
|
||||
|
||||
try {
|
||||
// Build where conditions
|
||||
const baseCondition = includePrivate
|
||||
? sql`(${find.isPublic} = 1 OR ${find.userId} = ${locals.user.id})`
|
||||
: sql`${find.isPublic} = 1`;
|
||||
// Get user's friends if needed
|
||||
let friendIds: string[] = [];
|
||||
if (includeFriends || includePrivate) {
|
||||
const friendships = await db
|
||||
.select({
|
||||
userId: friendship.userId,
|
||||
friendId: friendship.friendId
|
||||
})
|
||||
.from(friendship)
|
||||
.where(
|
||||
and(
|
||||
eq(friendship.status, 'accepted'),
|
||||
or(eq(friendship.userId, locals.user!.id), eq(friendship.friendId, locals.user!.id))
|
||||
)
|
||||
);
|
||||
|
||||
friendIds = friendships.map((f) => (f.userId === locals.user!.id ? f.friendId : f.userId));
|
||||
}
|
||||
|
||||
// Build privacy conditions
|
||||
const conditions = [sql`${find.isPublic} = 1`]; // Always include public finds
|
||||
|
||||
if (includePrivate) {
|
||||
// Include user's own finds (both public and private)
|
||||
conditions.push(sql`${find.userId} = ${locals.user!.id}`);
|
||||
}
|
||||
|
||||
if (includeFriends && friendIds.length > 0) {
|
||||
// Include friends' finds (both public and private)
|
||||
conditions.push(
|
||||
sql`${find.userId} IN (${sql.join(
|
||||
friendIds.map((id) => sql`${id}`),
|
||||
sql`, `
|
||||
)})`
|
||||
);
|
||||
}
|
||||
|
||||
const baseCondition = sql`(${sql.join(conditions, sql` OR `)})`;
|
||||
|
||||
let whereConditions = baseCondition;
|
||||
|
||||
|
||||
175
src/routes/api/friends/+server.ts
Normal file
175
src/routes/api/friends/+server.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { friendship, user } from '$lib/server/db/schema';
|
||||
import { eq, and, or } from 'drizzle-orm';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
|
||||
function generateFriendshipId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||
return encodeBase64url(bytes);
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const status = url.searchParams.get('status') || 'accepted';
|
||||
const type = url.searchParams.get('type') || 'friends'; // 'friends', 'sent', 'received'
|
||||
|
||||
try {
|
||||
let friendships;
|
||||
|
||||
if (type === 'friends') {
|
||||
// Get accepted friendships where user is either sender or receiver
|
||||
friendships = await db
|
||||
.select({
|
||||
id: friendship.id,
|
||||
userId: friendship.userId,
|
||||
friendId: friendship.friendId,
|
||||
status: friendship.status,
|
||||
createdAt: friendship.createdAt,
|
||||
friendUsername: user.username,
|
||||
friendProfilePictureUrl: user.profilePictureUrl
|
||||
})
|
||||
.from(friendship)
|
||||
.innerJoin(
|
||||
user,
|
||||
or(
|
||||
and(eq(friendship.friendId, user.id), eq(friendship.userId, locals.user.id)),
|
||||
and(eq(friendship.userId, user.id), eq(friendship.friendId, locals.user.id))
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(friendship.status, status),
|
||||
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
|
||||
)
|
||||
);
|
||||
} else if (type === 'sent') {
|
||||
// Get friend requests sent by current user
|
||||
friendships = await db
|
||||
.select({
|
||||
id: friendship.id,
|
||||
userId: friendship.userId,
|
||||
friendId: friendship.friendId,
|
||||
status: friendship.status,
|
||||
createdAt: friendship.createdAt,
|
||||
friendUsername: user.username,
|
||||
friendProfilePictureUrl: user.profilePictureUrl
|
||||
})
|
||||
.from(friendship)
|
||||
.innerJoin(user, eq(friendship.friendId, user.id))
|
||||
.where(and(eq(friendship.userId, locals.user.id), eq(friendship.status, status)));
|
||||
} else if (type === 'received') {
|
||||
// Get friend requests received by current user
|
||||
friendships = await db
|
||||
.select({
|
||||
id: friendship.id,
|
||||
userId: friendship.userId,
|
||||
friendId: friendship.friendId,
|
||||
status: friendship.status,
|
||||
createdAt: friendship.createdAt,
|
||||
friendUsername: user.username,
|
||||
friendProfilePictureUrl: user.profilePictureUrl
|
||||
})
|
||||
.from(friendship)
|
||||
.innerJoin(user, eq(friendship.userId, user.id))
|
||||
.where(and(eq(friendship.friendId, locals.user.id), eq(friendship.status, status)));
|
||||
}
|
||||
|
||||
// Generate signed URLs for profile pictures
|
||||
const friendshipsWithSignedUrls = await Promise.all(
|
||||
(friendships || []).map(async (friendship) => {
|
||||
let profilePictureUrl = friendship.friendProfilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
try {
|
||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for profile picture:', error);
|
||||
profilePictureUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...friendship,
|
||||
friendProfilePictureUrl: profilePictureUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return json(friendshipsWithSignedUrls);
|
||||
} catch (err) {
|
||||
console.error('Error loading friendships:', err);
|
||||
throw error(500, 'Failed to load friendships');
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const { friendId } = data;
|
||||
|
||||
if (!friendId) {
|
||||
throw error(400, 'Friend ID is required');
|
||||
}
|
||||
|
||||
if (friendId === locals.user.id) {
|
||||
throw error(400, 'Cannot send friend request to yourself');
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if friend exists
|
||||
const friendExists = await db.select().from(user).where(eq(user.id, friendId)).limit(1);
|
||||
if (friendExists.length === 0) {
|
||||
throw error(404, 'User not found');
|
||||
}
|
||||
|
||||
// Check if friendship already exists
|
||||
const existingFriendship = await db
|
||||
.select()
|
||||
.from(friendship)
|
||||
.where(
|
||||
or(
|
||||
and(eq(friendship.userId, locals.user.id), eq(friendship.friendId, friendId)),
|
||||
and(eq(friendship.userId, friendId), eq(friendship.friendId, locals.user.id))
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingFriendship.length > 0) {
|
||||
const status = existingFriendship[0].status;
|
||||
if (status === 'accepted') {
|
||||
throw error(400, 'Already friends');
|
||||
} else if (status === 'pending') {
|
||||
throw error(400, 'Friend request already sent');
|
||||
} else if (status === 'blocked') {
|
||||
throw error(400, 'Cannot send friend request');
|
||||
}
|
||||
}
|
||||
|
||||
// Create new friend request
|
||||
const newFriendship = await db
|
||||
.insert(friendship)
|
||||
.values({
|
||||
id: generateFriendshipId(),
|
||||
userId: locals.user.id,
|
||||
friendId,
|
||||
status: 'pending'
|
||||
})
|
||||
.returning();
|
||||
|
||||
return json({ success: true, friendship: newFriendship[0] });
|
||||
} catch (err) {
|
||||
console.error('Error sending friend request:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to send friend request');
|
||||
}
|
||||
};
|
||||
129
src/routes/api/friends/[friendshipId]/+server.ts
Normal file
129
src/routes/api/friends/[friendshipId]/+server.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { friendship } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const { friendshipId } = params;
|
||||
const data = await request.json();
|
||||
const { action } = data; // 'accept', 'decline', 'block'
|
||||
|
||||
if (!friendshipId) {
|
||||
throw error(400, 'Friendship ID is required');
|
||||
}
|
||||
|
||||
if (!action || !['accept', 'decline', 'block'].includes(action)) {
|
||||
throw error(400, 'Valid action is required (accept, decline, block)');
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the friendship
|
||||
const existingFriendship = await db
|
||||
.select()
|
||||
.from(friendship)
|
||||
.where(eq(friendship.id, friendshipId))
|
||||
.limit(1);
|
||||
|
||||
if (existingFriendship.length === 0) {
|
||||
throw error(404, 'Friendship not found');
|
||||
}
|
||||
|
||||
const friendshipRecord = existingFriendship[0];
|
||||
|
||||
// Check if user is authorized to modify this friendship
|
||||
// User can only accept/decline requests sent TO them, or cancel requests they sent
|
||||
const isReceiver = friendshipRecord.friendId === locals.user.id;
|
||||
const isSender = friendshipRecord.userId === locals.user.id;
|
||||
|
||||
if (!isReceiver && !isSender) {
|
||||
throw error(403, 'Not authorized to modify this friendship');
|
||||
}
|
||||
|
||||
// Only receivers can accept or decline pending requests
|
||||
if ((action === 'accept' || action === 'decline') && !isReceiver) {
|
||||
throw error(403, 'Only the recipient can accept or decline friend requests');
|
||||
}
|
||||
|
||||
// Only accept pending requests
|
||||
if (action === 'accept' && friendshipRecord.status !== 'pending') {
|
||||
throw error(400, 'Can only accept pending friend requests');
|
||||
}
|
||||
|
||||
let newStatus: string;
|
||||
if (action === 'accept') {
|
||||
newStatus = 'accepted';
|
||||
} else if (action === 'decline') {
|
||||
newStatus = 'declined';
|
||||
} else if (action === 'block') {
|
||||
newStatus = 'blocked';
|
||||
} else {
|
||||
throw error(400, 'Invalid action');
|
||||
}
|
||||
|
||||
// Update the friendship status
|
||||
const updatedFriendship = await db
|
||||
.update(friendship)
|
||||
.set({ status: newStatus })
|
||||
.where(eq(friendship.id, friendshipId))
|
||||
.returning();
|
||||
|
||||
return json({ success: true, friendship: updatedFriendship[0] });
|
||||
} catch (err) {
|
||||
console.error('Error updating friendship:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to update friendship');
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const { friendshipId } = params;
|
||||
|
||||
if (!friendshipId) {
|
||||
throw error(400, 'Friendship ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the friendship
|
||||
const existingFriendship = await db
|
||||
.select()
|
||||
.from(friendship)
|
||||
.where(eq(friendship.id, friendshipId))
|
||||
.limit(1);
|
||||
|
||||
if (existingFriendship.length === 0) {
|
||||
throw error(404, 'Friendship not found');
|
||||
}
|
||||
|
||||
const friendshipRecord = existingFriendship[0];
|
||||
|
||||
// Check if user is part of this friendship
|
||||
if (
|
||||
friendshipRecord.userId !== locals.user.id &&
|
||||
friendshipRecord.friendId !== locals.user.id
|
||||
) {
|
||||
throw error(403, 'Not authorized to delete this friendship');
|
||||
}
|
||||
|
||||
// Delete the friendship
|
||||
await db.delete(friendship).where(eq(friendship.id, friendshipId));
|
||||
|
||||
return json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Error deleting friendship:', err);
|
||||
if (err instanceof Error && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
throw error(500, 'Failed to delete friendship');
|
||||
}
|
||||
};
|
||||
97
src/routes/api/users/+server.ts
Normal file
97
src/routes/api/users/+server.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user, friendship } from '$lib/server/db/schema';
|
||||
import { eq, and, or, ilike, ne } from 'drizzle-orm';
|
||||
import { getSignedR2Url } from '$lib/server/r2';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const query = url.searchParams.get('q');
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20');
|
||||
|
||||
if (!query || query.trim().length < 2) {
|
||||
throw error(400, 'Search query must be at least 2 characters');
|
||||
}
|
||||
|
||||
try {
|
||||
// Search for users by username, excluding current user
|
||||
const users = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
profilePictureUrl: user.profilePictureUrl
|
||||
})
|
||||
.from(user)
|
||||
.where(and(ilike(user.username, `%${query.trim()}%`), ne(user.id, locals.user.id)))
|
||||
.limit(Math.min(limit, 50));
|
||||
|
||||
// Get existing friendships to determine relationship status
|
||||
const userIds = users.map((u) => u.id);
|
||||
let existingFriendships: Array<{
|
||||
userId: string;
|
||||
friendId: string;
|
||||
status: string;
|
||||
}> = [];
|
||||
|
||||
if (userIds.length > 0) {
|
||||
existingFriendships = await db
|
||||
.select({
|
||||
userId: friendship.userId,
|
||||
friendId: friendship.friendId,
|
||||
status: friendship.status
|
||||
})
|
||||
.from(friendship)
|
||||
.where(
|
||||
or(
|
||||
and(
|
||||
eq(friendship.userId, locals.user.id),
|
||||
or(...userIds.map((id) => eq(friendship.friendId, id)))
|
||||
),
|
||||
and(
|
||||
eq(friendship.friendId, locals.user.id),
|
||||
or(...userIds.map((id) => eq(friendship.userId, id)))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Create a map of friendship statuses
|
||||
const friendshipStatusMap = new Map<string, string>();
|
||||
existingFriendships.forEach((f) => {
|
||||
const otherUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
|
||||
friendshipStatusMap.set(otherUserId, f.status);
|
||||
});
|
||||
|
||||
// Generate signed URLs and add friendship status
|
||||
const usersWithSignedUrls = await Promise.all(
|
||||
users.map(async (userItem) => {
|
||||
let profilePictureUrl = userItem.profilePictureUrl;
|
||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||
try {
|
||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate signed URL for profile picture:', error);
|
||||
profilePictureUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
||||
|
||||
return {
|
||||
...userItem,
|
||||
profilePictureUrl,
|
||||
friendshipStatus
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return json(usersWithSignedUrls);
|
||||
} catch (err) {
|
||||
console.error('Error searching users:', err);
|
||||
throw error(500, 'Failed to search users');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user