sipher/convex/betterAuth/user/index.ts
Nixyan e7dd6c961d feat: enhance user status management and introduce nests functionality
- Updated user status handling to include optional user-set status, improving user experience during reconnections.
- Added new queries and mutations for managing nests, including fetching non-offline user IDs and forcing users offline.
- Introduced new database schema for nests, roles, and channels, enhancing the application's organizational structure.
- Updated dependencies in package.json and bun.lock for improved stability and compatibility.
- Refactored related components and API to support the new nests functionality.
2026-02-20 10:01:07 -03:00

446 lines
No EOL
12 KiB
TypeScript

import { v } from "convex/values";
import { Id } from "../_generated/dataModel";
import { mutation, MutationCtx, query, QueryCtx } from "../_generated/server";
// Overload signatures
async function userValidation(ctx: MutationCtx | QueryCtx, options: { required: false }): Promise<{ userId: Id<"user">; user: any } | null>;
async function userValidation(ctx: MutationCtx | QueryCtx, options?: { required?: true }): Promise<{ userId: Id<"user">; user: any }>;
// Implementation
async function userValidation(ctx: MutationCtx | QueryCtx, options?: { required?: boolean }) {
const required = options?.required ?? true;
const user = await ctx.auth.getUserIdentity();
if (!user) {
if (required) throw new Error("User not found");
return null;
}
const userId = ctx.db.normalizeId("user", user.subject as string) as Id<"user">;
if (!userId) {
if (required) throw new Error("User not found");
return null;
}
return { userId, user };
}
export const updateUserStatus = mutation({
args: {
status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")),
isUserSet: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
try {
const { userId } = await userValidation(ctx);
const isUserSet = args.isUserSet ?? false;
const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first();
if (userStatus) {
let resolvedStatus = args.status;
// Restore user-set status when reconnecting
if (args.status === "online" && !isUserSet && userStatus.userSetStatus?.isSet) {
resolvedStatus = userStatus.userSetStatus.status;
}
await ctx.db.patch(userStatus._id, {
status: resolvedStatus,
userSetStatus: isUserSet
? { status: args.status, updatedAt: Date.now(), isSet: true }
: userStatus.userSetStatus,
updatedAt: Date.now(),
});
} else {
await ctx.db.insert("userStatus", {
userId: userId,
status: args.status,
userSetStatus: {
status: args.status,
updatedAt: Date.now(),
isSet: isUserSet,
},
updatedAt: Date.now(),
});
}
return { success: true, message: "User status updated successfully" };
} catch (error) {
console.error("Error updating user status:", error);
throw new Error("Failed to update user status");
}
},
});
export const getUserStatus = query({
handler: async (ctx) => {
const validation = await userValidation(ctx, { required: false });
if (!validation) {
return null; // User not authenticated
}
const { userId } = validation;
const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first();
return userStatus;
}
});
export const getNonOfflineUserIds = query({
args: {},
handler: async (ctx) => {
const results: { userId: string; status: string; isUserSet: boolean }[] = [];
for (const status of ["online", "busy", "away"] as const) {
const records = await ctx.db
.query("userStatus")
.withIndex("status", (q) => q.eq("status", status))
.collect();
for (const record of records) {
results.push({
userId: record.userId,
status: record.status,
isUserSet: record.userSetStatus?.isSet ?? false,
});
}
}
return results;
},
});
export const forceUserOffline = mutation({
args: {
userId: v.string(),
},
handler: async (ctx, args) => {
const normalizedId = ctx.db.normalizeId("user", args.userId);
if (!normalizedId) return;
const userStatus = await ctx.db
.query("userStatus")
.withIndex("userId", (q) => q.eq("userId", normalizedId))
.first();
if (!userStatus || userStatus.status === "offline") return;
await ctx.db.patch(userStatus._id, {
status: "offline",
userSetStatus: userStatus.userSetStatus ? {
status: userStatus.userSetStatus.status,
updatedAt: Date.now(),
isSet: userStatus.userSetStatus.isSet,
} : undefined,
updatedAt: Date.now(),
});
console.log(`[forceUserOffline] Set user ${args.userId} offline (was: ${userStatus.status})`);
},
});
export const updateUserMetadata = mutation({
args: {
metadata: v.object({
phrasePreference: v.union(v.literal("comforting"), v.literal("mocking"), v.literal("both")),
}),
},
handler: async (ctx, args) => {
const { userId } = await userValidation(ctx);
return ctx.db.patch("user", userId, {
metadata: args.metadata,
});
},
});
export const sendFriendRequest = mutation({
args: {
username: v.string(),
},
handler: async (ctx, args) => {
const { userId, user: currentUser } = await userValidation(ctx);
// Find the target user
const targetUser = await ctx.db.query("user").withIndex("byName", (q) => q.eq("name", args.username)).first();
if (!targetUser) {
throw new Error("User not found");
}
// Check if trying to send request to yourself
if (targetUser._id === userId) {
throw new Error("You cannot send a friend request to yourself");
}
// Check if already friends
const existingFriendship = await ctx.db
.query("friends")
.withIndex("userId_friendId", (q) => q.eq("userId", userId).eq("friendId", targetUser._id))
.first();
if (existingFriendship) {
throw new Error("You are already friends with this user");
}
// Check for existing requests in both directions
const existingRequests = await ctx.db
.query("friendRequests")
.filter((q) =>
q.or(
q.and(
q.eq(q.field("userId"), userId),
q.eq(q.field("requestTo"), targetUser._id)
),
q.and(
q.eq(q.field("userId"), targetUser._id),
q.eq(q.field("requestTo"), userId)
)
)
)
.filter((q) => q.eq(q.field("acceptedAt"), undefined))
.filter((q) => q.eq(q.field("declinedAt"), undefined))
.collect();
const existingSentRequest = existingRequests.find(r => r.userId === userId);
const incomingRequest = existingRequests.find(r => r.userId === targetUser._id);
if (existingSentRequest) {
throw new Error("You have already sent a friend request to this user");
}
if (incomingRequest) {
const timestamp = Date.now();
// Auto-accept the incoming request
await ctx.db.patch(incomingRequest._id, {
acceptedAt: timestamp,
});
// Create bidirectional friendship entries
await Promise.all([
ctx.db.insert("friends", {
userId: userId,
friendId: targetUser._id,
createdAt: timestamp,
}),
ctx.db.insert("friends", {
userId: targetUser._id,
friendId: userId,
createdAt: timestamp,
}),
]);
return {
success: true,
message: "Friend request accepted automatically (they had already sent you a request)",
};
}
// Create the friend request (single row)
const requestId = crypto.randomUUID();
await ctx.db.insert("friendRequests", {
userId: userId,
requestTo: targetUser._id,
method: "send",
requestId,
createdAt: Date.now(),
});
return {
success: true,
message: "Friend request sent successfully",
};
}
})
export const answerFriendRequest = mutation({
args: {
requestId: v.string(),
answer: v.union(v.literal("accept"), v.literal("decline"), v.literal("ignore")),
},
handler: async (ctx, args) => {
const { userId } = await userValidation(ctx);
// Get the friend request
const request = await ctx.db
.query("friendRequests")
.withIndex("requestId", (q) => q.eq("requestId", args.requestId))
.first();
if (!request) {
throw new Error("Request not found");
}
// Verify current user is the recipient
if (request.requestTo !== userId) {
throw new Error("You are not the recipient of this request");
}
// Check if already answered
if (request.acceptedAt || request.declinedAt || request.ignoredAt) {
throw new Error("Request already answered");
}
const timestamp = Date.now();
// Update the request based on the answer
switch (args.answer) {
case "accept":
// Update request status
await ctx.db.patch(request._id, { acceptedAt: timestamp });
// Create bidirectional friendship entries
await Promise.all([
ctx.db.insert("friends", {
userId: userId,
friendId: request.userId,
createdAt: timestamp,
}),
ctx.db.insert("friends", {
userId: request.userId,
friendId: userId,
createdAt: timestamp,
}),
]);
break;
case "decline":
await ctx.db.patch(request._id, { declinedAt: timestamp });
break;
case "ignore":
await ctx.db.patch(request._id, { ignoredAt: timestamp });
break;
}
return {
success: true,
message: `Friend request ${args.answer}ed successfully`,
};
}
})
export const getFriendRequests = query({
handler: async (ctx) => {
const { userId } = await userValidation(ctx);
// Get all unanswered requests involving this user (sent by them OR sent to them)
const allRequests = await ctx.db
.query("friendRequests")
.filter((q) =>
q.or(
q.eq(q.field("userId"), userId), // Requests sent by me
q.eq(q.field("requestTo"), userId) // Requests sent to me
)
)
.filter((q) => q.eq(q.field("acceptedAt"), undefined))
.filter((q) => q.eq(q.field("declinedAt"), undefined))
.filter((q) => q.eq(q.field("ignoredAt"), undefined))
.collect();
// Transform to include method field based on perspective
const requestsWithMethod = await Promise.all(
allRequests.map(async (request) => {
const isSentByMe = request.userId === userId;
const otherUserId = isSentByMe ? request.requestTo : request.userId;
const otherUser = await ctx.db.get(otherUserId);
return {
id: request.requestId,
_id: request._id,
userId: otherUserId,
username: otherUser?.username || otherUser?.displayUsername || otherUser?.name || "Unknown",
avatar: otherUser?.image || "",
createdAt: request.createdAt,
method: isSentByMe ? "send" : "receive",
};
})
);
return requestsWithMethod;
}
})
export const getFriends = query({
handler: async (ctx) => {
const { userId } = await userValidation(ctx);
// Get all friendships for this user
const friendships = await ctx.db
.query("friends")
.withIndex("userId", (q) => q.eq("userId", userId))
.collect();
// Populate friend data with relevant fields
const friends = await Promise.all(
friendships.map(async (friendship) => {
const friend = await ctx.db.get(friendship.friendId);
const friendStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", friendship.friendId)).first();
if (!friend) return null;
return {
_id: friend._id,
id: friend._id,
name: friend.name,
username: friend.username,
displayUsername: friend.displayUsername,
image: friend.image,
friendshipCreatedAt: friendship.createdAt,
status: friendStatus ? {
status: friendStatus.status,
isUserSet: friendStatus.userSetStatus?.isSet ?? false,
} : {
status: "offline" as const,
isUserSet: false,
},
};
})
);
return friends.filter(Boolean);
}
})
export const getParticipantDetails = query({
args: {
participantIds: v.array(v.string()),
},
handler: async (ctx, args) => {
const { participantIds } = args;
const { userId } = await userValidation(ctx);
if (!userId) throw new Error("User not found");
if (participantIds.length === 0) return [];
const normalizedParticipantIds = participantIds.map((id) => ctx.db.normalizeId("user", id));
if (normalizedParticipantIds.length === 0) return [];
// Filter out all null values
const filteredParticipantIds = normalizedParticipantIds.filter((id) => id !== null);
if (filteredParticipantIds.length === 0) return [];
const participantDetails = await Promise.all(filteredParticipantIds.map(async (id) => {
const participant = await ctx.db.get("user", id)
const participantStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", id)).first();
const participantOlmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", id)).first();
if (!participant) return null;
// Ensure backward compatibility with old olmAccount records
const olmAccountWithDefaults = participantOlmAccount ? {
...participantOlmAccount,
keyVersion: participantOlmAccount.keyVersion ?? 1,
createdAt: participantOlmAccount.createdAt ?? participantOlmAccount._creationTime,
updatedAt: participantOlmAccount.updatedAt ?? participantOlmAccount._creationTime,
} : null;
return {
id: participant._id,
name: participant.name,
username: participant.username,
displayUsername: participant.displayUsername,
image: participant.image,
status: participantStatus?.status || "offline",
olmAccount: olmAccountWithDefaults,
}
}));
return participantDetails
}
})