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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@

- [x] Shareable invite links (not just email invites) — schema, API, and UI implemented
- [x] Ally (friend) system with requests — schema, API, allies page, user profile popover with ally actions
- [ ] User blocking
- [x] Direct messages — create 1:1 and group DMs with allies, new DM dialog
- [x] User blocking — schema, API (block/unblock/list), realtime DM enforcement, blocked tab on allies page, block/unblock in profile popover, message collapse with click-to-reveal, typing/DM filtering
- [ ] Privacy settings
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Phase 4 — Tests & CI/CD
Expand All @@ -55,8 +56,13 @@
- [x] Typing indicators
- [x] Pinned messages panel
- [ ] Thread support
- [ ] Desktop notifications (browser Notification API for mentions, DMs, etc.)
- [ ] Notification preferences
- [x] Reaction tooltips (who reacted with each emoji)
- [x] User profile popover (bio, status, online indicator, ally actions)
- [x] Remember last visited channel per guild (localStorage)
- [ ] Error handling & loading state improvements
- [x] Username editing in account settings (with availability check)
- [ ] Other settings pages

## Phase 6 — Infrastructure
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import configureOpenAPI from "@/lib/helpers/openapi/configure-openapi"
import { globalRateLimit } from "@/middleware/rate-limit"
import index from "@/routes/index.route"
import alliesRouter from "@/routes/v1/allies/index"
import blocksRouter from "@/routes/v1/blocks/index"
import channelsRouter from "@/routes/v1/channels/index"
import dmsRouter from "@/routes/v1/dms/index"
import guildsRouter from "@/routes/v1/guilds/index"
import invitesRouter from "@/routes/v1/invites/index"
import privacySettingsRouter from "@/routes/v1/privacy-settings/index"
import uploadsRouter from "@/routes/v1/uploads/index"
import usersRouter from "@/routes/v1/users/index"
import waitlistRouter from "@/routes/waitlist/index"
Expand Down Expand Up @@ -38,9 +40,11 @@ app.route("/", index)
const routes = app
.route("/", waitlistRouter)
.route("/v1", alliesRouter)
.route("/v1", blocksRouter)
.route("/v1", channelsRouter)
.route("/v1", guildsRouter)
.route("/v1", invitesRouter)
.route("/v1", privacySettingsRouter)
.route("/v1", dmsRouter)
.route("/v1", uploadsRouter)
.route("/v1", usersRouter)
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/routes/v1/allies/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,52 @@ export const sendAllyRequest: AppRouteHandler<SendAllyRequestRoute> = async (
)
}

// Check if either user has blocked the other
const blockExists = await db
.select({ id: schema.userBlock.id })
.from(schema.userBlock)
.where(
or(
and(
eq(schema.userBlock.blockerId, currentUser.id),
eq(schema.userBlock.blockedId, targetUserId)
),
and(
eq(schema.userBlock.blockerId, targetUserId),
eq(schema.userBlock.blockedId, currentUser.id)
)
)
)
.limit(1)
.then((rows) => rows[0])

if (blockExists) {
return c.json(
{ success: false, message: "Unable to send ally request" },
HttpStatusCodes.BAD_REQUEST
)
}

// Check target user's privacy settings for ally requests
const targetPrivacy = await db
.select({
allyRequestPrivacy: schema.userPrivacySettings.allyRequestPrivacy,
})
.from(schema.userPrivacySettings)
.where(eq(schema.userPrivacySettings.userId, targetUserId))
.limit(1)
.then((rows) => rows[0])

if (targetPrivacy?.allyRequestPrivacy === "no_one") {
return c.json(
{
success: false,
message: "This user is not accepting ally requests",
},
HttpStatusCodes.FORBIDDEN
)
}

// Check for existing relationship (in either direction)
const existing = await db
.select({
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/v1/allies/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const sendAllyRequest = createRoute({
}),
[HttpStatusCodes.BAD_REQUEST]: badRequestSchema,
[HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema,
[HttpStatusCodes.FORBIDDEN]: forbiddenSchema,
[HttpStatusCodes.NOT_FOUND]: notFoundSchema,
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema,
},
Expand Down
136 changes: 136 additions & 0 deletions apps/api/src/routes/v1/blocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { and, db, desc, eq, or, schema } from "@repo/db"
import * as HttpStatusCodes from "@/lib/helpers/http/status-codes"
import type { AppRouteHandler } from "@/lib/types/app-types"
import type {
BlockUserRoute,
ListBlockedUsersRoute,
UnblockUserRoute,
} from "./routes"
Comment thread
BuckyMcYolo marked this conversation as resolved.

export const blockUser: AppRouteHandler<BlockUserRoute> = async (c) => {
const currentUser = c.var.user
const { userId: targetUserId } = c.req.valid("json")

if (currentUser.id === targetUserId) {
return c.json(
{ success: false, message: "Cannot block yourself" },
HttpStatusCodes.BAD_REQUEST
)
}

// Check target user exists
const targetUser = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.id, targetUserId))
.limit(1)
.then((rows) => rows[0])

if (!targetUser) {
return c.json(
{ success: false, message: "User not found" },
HttpStatusCodes.NOT_FOUND
)
}

// Atomically: insert block + remove any ally relationship
const result = await db.transaction(async (tx) => {
const inserted = await tx
.insert(schema.userBlock)
.values({
blockerId: currentUser.id,
blockedId: targetUserId,
})
.onConflictDoNothing()
.returning()

if (inserted.length === 0) {
return { alreadyBlocked: true }
}

// Delete any ally request between the two users (in either direction)
await tx
.delete(schema.allyRequest)
.where(
or(
and(
eq(schema.allyRequest.senderId, currentUser.id),
eq(schema.allyRequest.receiverId, targetUserId)
),
and(
eq(schema.allyRequest.senderId, targetUserId),
eq(schema.allyRequest.receiverId, currentUser.id)
)
)
)

return { alreadyBlocked: false }
})

if (result.alreadyBlocked) {
return c.json(
{ success: false, message: "User is already blocked" },
HttpStatusCodes.BAD_REQUEST
)
}

return c.json({ success: true }, HttpStatusCodes.OK)
}

export const unblockUser: AppRouteHandler<UnblockUserRoute> = async (c) => {
const currentUser = c.var.user
const { userId: targetUserId } = c.req.valid("param")

const deleted = await db
.delete(schema.userBlock)
.where(
and(
eq(schema.userBlock.blockerId, currentUser.id),
eq(schema.userBlock.blockedId, targetUserId)
)
)
.returning()

if (deleted.length === 0) {
return c.json(
{ success: false, message: "Block not found" },
HttpStatusCodes.NOT_FOUND
)
}

return c.json({ success: true }, HttpStatusCodes.OK)
}

export const listBlockedUsers: AppRouteHandler<ListBlockedUsersRoute> = async (
c
) => {
const currentUser = c.var.user

const blocks = await db
.select({
id: schema.user.id,
name: schema.user.name,
username: schema.user.username,
displayUsername: schema.user.displayUsername,
image: schema.user.image,
blockedAt: schema.userBlock.createdAt,
})
.from(schema.userBlock)
.innerJoin(schema.user, eq(schema.userBlock.blockedId, schema.user.id))
.where(eq(schema.userBlock.blockerId, currentUser.id))
.orderBy(desc(schema.userBlock.createdAt))

Comment thread
coderabbitai[bot] marked this conversation as resolved.
return c.json(
{
blockedUsers: blocks.map((b) => ({
id: b.id,
name: b.name,
username: b.username,
displayUsername: b.displayUsername,
image: b.image,
blockedAt: b.blockedAt.toISOString(),
})),
},
HttpStatusCodes.OK
)
}
10 changes: 10 additions & 0 deletions apps/api/src/routes/v1/blocks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createRouter } from "@/lib/helpers/app/create-app"
import * as handlers from "@/routes/v1/blocks/handlers"
import * as routes from "@/routes/v1/blocks/routes"

const blocksRouter = createRouter()
.openapi(routes.blockUser, handlers.blockUser)
.openapi(routes.unblockUser, handlers.unblockUser)
.openapi(routes.listBlockedUsers, handlers.listBlockedUsers)

export default blocksRouter
87 changes: 87 additions & 0 deletions apps/api/src/routes/v1/blocks/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { createRoute } from "@hono/zod-openapi"
import * as HttpStatusCodes from "@/lib/helpers/http/status-codes"
import jsonContent from "@/lib/helpers/openapi/json-content"
import {
badRequestSchema,
internalServerErrorSchema,
notFoundSchema,
unauthorizedSchema,
} from "@/lib/helpers/openapi/schemas"
import { sessionAuthMiddleware } from "@/middleware/session-auth"
import {
blockUserBodySchema,
blockUserIdParamsSchema,
blockUserResponseSchema,
listBlockedUsersResponseSchema,
unblockUserResponseSchema,
} from "./schema"

export const blockUser = createRoute({
path: "/blocks",
method: "post",
summary: "Block a user",
description:
"Blocks a user. Removes any existing ally relationship between the users.",
tags: ["Blocks"],
middleware: [sessionAuthMiddleware] as const,
request: {
body: jsonContent({
schema: blockUserBodySchema,
description: "User to block",
}),
},
responses: {
[HttpStatusCodes.OK]: jsonContent({
schema: blockUserResponseSchema,
description: "User blocked",
}),
[HttpStatusCodes.BAD_REQUEST]: badRequestSchema,
[HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema,
[HttpStatusCodes.NOT_FOUND]: notFoundSchema,
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema,
},
})

export type BlockUserRoute = typeof blockUser

export const unblockUser = createRoute({
path: "/blocks/{userId}",
method: "delete",
summary: "Unblock a user",
description: "Removes a block on the specified user.",
tags: ["Blocks"],
middleware: [sessionAuthMiddleware] as const,
request: {
params: blockUserIdParamsSchema,
},
responses: {
[HttpStatusCodes.OK]: jsonContent({
schema: unblockUserResponseSchema,
description: "User unblocked",
}),
[HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema,
[HttpStatusCodes.NOT_FOUND]: notFoundSchema,
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema,
},
})

export type UnblockUserRoute = typeof unblockUser

export const listBlockedUsers = createRoute({
path: "/blocks",
method: "get",
summary: "List blocked users",
description: "Returns all users blocked by the current user.",
tags: ["Blocks"],
middleware: [sessionAuthMiddleware] as const,
responses: {
[HttpStatusCodes.OK]: jsonContent({
schema: listBlockedUsersResponseSchema,
description: "List of blocked users",
}),
[HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema,
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema,
},
})

export type ListBlockedUsersRoute = typeof listBlockedUsers
42 changes: 42 additions & 0 deletions apps/api/src/routes/v1/blocks/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { z } from "@hono/zod-openapi"

// ── Path Params ──────────────────────────────────────────

export const blockUserIdParamsSchema = z.object({
userId: z
.string()
.uuid()
.openapi({
param: { name: "userId", in: "path", required: true },
example: "00000000-0000-0000-0000-000000000000",
}),
})

// ── Request Schemas ──────────────────────────────────────

export const blockUserBodySchema = z.object({
userId: z.string().uuid(),
})

// ── Response Schemas ──────────────────────────────────────

const blockedUserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
username: z.string().nullable(),
displayUsername: z.string().nullable(),
image: z.string().nullable(),
blockedAt: z.string().datetime(),
})

export const blockUserResponseSchema = z.object({
success: z.literal(true),
})

export const unblockUserResponseSchema = z.object({
success: z.literal(true),
})

export const listBlockedUsersResponseSchema = z.object({
blockedUsers: z.array(blockedUserSchema),
})
Loading