Compare commits

...

4 Commits

Author SHA1 Message Date
semantic-release-bot
50f45ab08e chore(release): 3.8.0 [skip ci]
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)

### Features

* implement proper retired room behavior with member expulsion ([a2d5368](a2d53680f2))
2025-10-14 13:08:48 +00:00
Thomas Hallock
a2d53680f2 feat: implement proper retired room behavior with member expulsion
**Join endpoint changes:**
- Only room creator can access retired rooms
- All other users blocked with 410 status and clear message

**Settings endpoint changes:**
- When room set to 'retired', all non-owner members immediately expelled
- Expelled members removed from database
- Each expulsion recorded in member history
- Socket notifications sent to all expelled members (kicked-from-room event)
- Owner notified of member expulsions via member-left event

**Member expulsion flow:**
- Similar pattern to ban ejection
- Expelled members see "kicked from room" modal with reason "Room has been retired"
- All expelled members logged in history with 'left' action

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:07:49 -05:00
semantic-release-bot
b9e7267f15 chore(release): 3.7.1 [skip ci]
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)

### Bug Fixes

* improve join request approval error handling with actionable messages ([57bf846](57bf8460c8))
2025-10-14 13:03:49 +00:00
Thomas Hallock
57bf8460c8 fix: improve join request approval error handling with actionable messages
- Add requestError state to track approval/deny errors
- Toast dismisses only on successful approval/deny
- On error, toast remains visible and displays error message inline
- Parse API error response to show meaningful error messages
- User can retry approval/deny action after error
- Replace generic alert() with styled error message within toast

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:02:56 -05:00
5 changed files with 144 additions and 11 deletions

View File

@@ -1,3 +1,17 @@
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)
### Features
* implement proper retired room behavior with member expulsion ([a2d5368](https://github.com/antialias/soroban-abacus-flashcards/commit/a2d53680f27db04b2cd09973e62a76c5a7d4ce06))
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)
### Bug Fixes
* improve join request approval error handling with actionable messages ([57bf846](https://github.com/antialias/soroban-abacus-flashcards/commit/57bf8460c8ecff374355bfb93f4b06dfbb148273))
## [3.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.3...v3.7.0) (2025-10-14)

View File

@@ -38,9 +38,10 @@ export async function POST(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 })
}
// Check if user is already a member (for locked room access)
// Check if user is already a member (for locked/retired room access)
const members = await getRoomMembers(roomId)
const isExistingMember = members.some((m) => m.userId === viewerId)
const isRoomCreator = room.createdBy === viewerId
// Validate access mode
switch (room.accessMode) {
@@ -55,7 +56,14 @@ export async function POST(req: NextRequest, context: RouteContext) {
break
case 'retired':
return NextResponse.json({ error: 'This room has been retired' }, { status: 410 })
// Only the room creator can access retired rooms
if (!isRoomCreator) {
return NextResponse.json(
{ error: 'This room has been retired and is only accessible to the owner' },
{ status: 410 }
)
}
break
case 'password': {
if (!body.password) {

View File

@@ -1,9 +1,12 @@
import { eq } from 'drizzle-orm'
import bcrypt from 'bcryptjs'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { recordRoomMemberHistory } from '@/lib/arcade/room-member-history'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
import bcrypt from 'bcryptjs'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -79,6 +82,72 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
.where(eq(schema.arcadeRooms.id, roomId))
.returning()
// If setting to retired, expel all non-owner members
if (body.accessMode === 'retired') {
const nonOwnerMembers = members.filter((m) => !m.isCreator)
if (nonOwnerMembers.length > 0) {
// Remove all non-owner members from the room
await db.delete(schema.roomMembers).where(
and(
eq(schema.roomMembers.roomId, roomId),
// Delete all members except the creator
eq(schema.roomMembers.isCreator, false)
)
)
// Record in history for each expelled member
for (const member of nonOwnerMembers) {
await recordRoomMemberHistory({
roomId,
userId: member.userId,
displayName: member.displayName,
action: 'left',
})
}
// Broadcast updates via socket
const io = await getSocketIO()
if (io) {
try {
// Get updated member list (should only be the owner now)
const updatedMembers = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Notify each expelled member
for (const member of nonOwnerMembers) {
io.to(`user:${member.userId}`).emit('kicked-from-room', {
roomId,
kickedBy: currentMember.displayName,
reason: 'Room has been retired',
})
}
// Notify the owner that members were expelled
io.to(`room:${roomId}`).emit('member-left', {
roomId,
userId: nonOwnerMembers.map((m) => m.userId),
members: updatedMembers,
memberPlayers: memberPlayersObj,
reason: 'room-retired',
})
console.log(
`[Settings API] Expelled ${nonOwnerMembers.length} members from retired room ${roomId}`
)
} catch (socketError) {
console.error('[Settings API] Failed to broadcast member expulsion:', socketError)
}
}
}
}
return NextResponse.json({ room: updatedRoom }, { status: 200 })
} catch (error: any) {
console.error('Failed to update room settings:', error)

View File

@@ -31,6 +31,7 @@ export function ModerationNotifications({
const [showJoinRequestToast, setShowJoinRequestToast] = useState(false)
const [isAcceptingInvitation, setIsAcceptingInvitation] = useState(false)
const [isProcessingRequest, setIsProcessingRequest] = useState(false)
const [requestError, setRequestError] = useState<string | null>(null)
const { mutateAsync: joinRoom } = useJoinRoom()
// Handle report toast (for hosts)
@@ -50,6 +51,7 @@ export function ModerationNotifications({
useEffect(() => {
if (moderationEvent?.type === 'join-request') {
setShowJoinRequestToast(true)
setRequestError(null) // Clear any previous errors
}
}, [moderationEvent])
@@ -58,6 +60,8 @@ export function ModerationNotifications({
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
setIsProcessingRequest(true)
setRequestError(null) // Clear any previous errors
try {
const response = await fetch(
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/approve`,
@@ -68,10 +72,11 @@ export function ModerationNotifications({
)
if (!response.ok) {
throw new Error('Failed to approve join request')
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to approve join request')
}
// Close toast and event
// Close toast and event on success
setShowJoinRequestToast(false)
onClose()
@@ -79,7 +84,8 @@ export function ModerationNotifications({
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
} catch (error) {
console.error('Failed to approve join request:', error)
alert(error instanceof Error ? error.message : 'Failed to approve request')
// Keep toast visible and show error message
setRequestError(error instanceof Error ? error.message : 'Failed to approve request')
} finally {
setIsProcessingRequest(false)
}
@@ -90,6 +96,8 @@ export function ModerationNotifications({
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
setIsProcessingRequest(true)
setRequestError(null) // Clear any previous errors
try {
const response = await fetch(
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/deny`,
@@ -100,10 +108,11 @@ export function ModerationNotifications({
)
if (!response.ok) {
throw new Error('Failed to deny join request')
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to deny join request')
}
// Close toast and event
// Close toast and event on success
setShowJoinRequestToast(false)
onClose()
@@ -111,7 +120,8 @@ export function ModerationNotifications({
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
} catch (error) {
console.error('Failed to deny join request:', error)
alert(error instanceof Error ? error.message : 'Failed to deny request')
// Keep toast visible and show error message
setRequestError(error instanceof Error ? error.message : 'Failed to deny request')
} finally {
setIsProcessingRequest(false)
}
@@ -515,6 +525,38 @@ export function ModerationNotifications({
<strong>{moderationEvent.data.requesterName}</strong> wants to join your room
</Toast.Description>
{/* Error message */}
{requestError && (
<div
style={{
padding: '8px 10px',
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid rgba(239, 68, 68, 0.4)',
borderRadius: '6px',
marginBottom: '12px',
}}
>
<div
style={{
fontSize: '12px',
color: 'rgba(254, 202, 202, 1)',
fontWeight: '600',
marginBottom: '2px',
}}
>
Error
</div>
<div
style={{
fontSize: '11px',
color: 'rgba(255, 255, 255, 0.9)',
}}
>
{requestError}
</div>
</div>
)}
{/* Action buttons */}
<div style={{ display: 'flex', gap: '8px' }}>
<button

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "3.7.0",
"version": "3.8.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [