fix: version guard uses static file, members table shows project name with round badge
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m12s
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m12s
Version guard: - Replace API route with prebuild-generated public/build-id.json - Captures build ID on first load, only notifies on mismatch - Fixes false positive refresh prompts from env mismatch Members table (applicants): - Show project name + round badge instead of round name + state - Red badge for rejected, gray for withdrawn, green for passed, outline for active rounds - Include projectName in applicantRoundInfo from backend Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -61,3 +61,4 @@ build-output.txt
|
|||||||
|
|
||||||
# Private keys and secrets
|
# Private keys and secrets
|
||||||
private/
|
private/
|
||||||
|
public/build-id.json
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import type { NextConfig } from 'next'
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
env: {
|
|
||||||
NEXT_PUBLIC_BUILD_ID: Date.now().toString(),
|
|
||||||
},
|
|
||||||
serverExternalPackages: ['@prisma/client', 'minio'],
|
serverExternalPackages: ['@prisma/client', 'minio'],
|
||||||
typescript: {
|
typescript: {
|
||||||
// We run tsc --noEmit separately before each push
|
// We run tsc --noEmit separately before each push
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
"prebuild": "node -e \"require('fs').writeFileSync('public/build-id.json', JSON.stringify({buildId: Date.now().toString()}))\"",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
export const dynamic = 'force-static'
|
|
||||||
|
|
||||||
export function GET() {
|
|
||||||
return NextResponse.json({ buildId: process.env.NEXT_PUBLIC_BUILD_ID })
|
|
||||||
}
|
|
||||||
@@ -428,20 +428,19 @@ export function MembersContent() {
|
|||||||
<div>
|
<div>
|
||||||
{user.role === 'APPLICANT' ? (
|
{user.role === 'APPLICANT' ? (
|
||||||
(() => {
|
(() => {
|
||||||
const info = (user as unknown as { applicantRoundInfo?: { roundName: string; state: string } | null }).applicantRoundInfo
|
const info = (user as unknown as { applicantRoundInfo?: { projectName: string; roundName: string; state: string } | null }).applicantRoundInfo
|
||||||
if (!info) return <span className="text-sm text-muted-foreground">-</span>
|
if (!info) return <span className="text-sm text-muted-foreground">-</span>
|
||||||
const stateColor = info.state === 'REJECTED' ? 'destructive' as const
|
const stateColor = info.state === 'REJECTED' ? 'destructive' as const
|
||||||
: info.state === 'WITHDRAWN' ? 'secondary' as const
|
: info.state === 'WITHDRAWN' ? 'secondary' as const
|
||||||
: info.state === 'PASSED' ? 'success' as const
|
: info.state === 'PASSED' ? 'success' as const
|
||||||
: 'default' as const
|
: 'outline' as const
|
||||||
const stateLabel = info.state === 'IN_PROGRESS' ? 'Active'
|
const stateLabel = info.state === 'REJECTED' ? 'Rejected'
|
||||||
: info.state === 'PENDING' ? 'Pending'
|
: info.state === 'WITHDRAWN' ? 'Withdrawn'
|
||||||
: info.state === 'COMPLETED' ? 'Completed'
|
|
||||||
: info.state === 'PASSED' ? 'Passed'
|
: info.state === 'PASSED' ? 'Passed'
|
||||||
: info.state
|
: info.roundName
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-sm">{info.roundName}</span>
|
<span className="text-sm font-medium truncate max-w-[200px]">{info.projectName}</span>
|
||||||
<Badge variant={stateColor} className="w-fit text-[10px] px-1.5 py-0">
|
<Badge variant={stateColor} className="w-fit text-[10px] px-1.5 py-0">
|
||||||
{stateLabel}
|
{stateLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -543,25 +542,24 @@ export function MembersContent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{user.role === 'APPLICANT' ? 'Current Round' : 'Assignments'}
|
{user.role === 'APPLICANT' ? 'Project' : 'Assignments'}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{user.role === 'APPLICANT' ? (
|
{user.role === 'APPLICANT' ? (
|
||||||
(() => {
|
(() => {
|
||||||
const info = (user as unknown as { applicantRoundInfo?: { roundName: string; state: string } | null }).applicantRoundInfo
|
const info = (user as unknown as { applicantRoundInfo?: { projectName: string; roundName: string; state: string } | null }).applicantRoundInfo
|
||||||
if (!info) return <span className="text-muted-foreground">-</span>
|
if (!info) return <span className="text-muted-foreground">-</span>
|
||||||
const stateColor = info.state === 'REJECTED' ? 'destructive' as const
|
const stateColor = info.state === 'REJECTED' ? 'destructive' as const
|
||||||
: info.state === 'WITHDRAWN' ? 'secondary' as const
|
: info.state === 'WITHDRAWN' ? 'secondary' as const
|
||||||
: info.state === 'PASSED' ? 'success' as const
|
: info.state === 'PASSED' ? 'success' as const
|
||||||
: 'default' as const
|
: 'outline' as const
|
||||||
const stateLabel = info.state === 'IN_PROGRESS' ? 'Active'
|
const stateLabel = info.state === 'REJECTED' ? 'Rejected'
|
||||||
: info.state === 'PENDING' ? 'Pending'
|
: info.state === 'WITHDRAWN' ? 'Withdrawn'
|
||||||
: info.state === 'COMPLETED' ? 'Completed'
|
|
||||||
: info.state === 'PASSED' ? 'Passed'
|
: info.state === 'PASSED' ? 'Passed'
|
||||||
: info.state
|
: info.roundName
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex flex-col items-end gap-0.5">
|
||||||
<span>{info.roundName}</span>
|
<span className="truncate max-w-[160px]">{info.projectName}</span>
|
||||||
<Badge variant={stateColor} className="text-[10px] px-1.5 py-0">
|
<Badge variant={stateColor} className="text-[10px] px-1.5 py-0">
|
||||||
{stateLabel}
|
{stateLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
const CLIENT_BUILD_ID = process.env.NEXT_PUBLIC_BUILD_ID
|
// Capture the build ID when this module first loads (from the current deployment's JS bundle).
|
||||||
|
// On subsequent fetches of /build-id.json, if the value differs, a new deploy happened.
|
||||||
|
let initialBuildId: string | null = null
|
||||||
|
|
||||||
export function VersionGuard() {
|
export function VersionGuard() {
|
||||||
const notified = useRef(false)
|
const notified = useRef(false)
|
||||||
@@ -12,10 +14,19 @@ export function VersionGuard() {
|
|||||||
async function checkVersion() {
|
async function checkVersion() {
|
||||||
if (notified.current) return
|
if (notified.current) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/version', { cache: 'no-store' })
|
const res = await fetch('/build-id.json?t=' + Date.now())
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const { buildId } = await res.json()
|
const { buildId } = await res.json()
|
||||||
if (buildId && CLIENT_BUILD_ID && buildId !== CLIENT_BUILD_ID) {
|
if (!buildId) return
|
||||||
|
|
||||||
|
// First load — capture the build ID
|
||||||
|
if (initialBuildId === null) {
|
||||||
|
initialBuildId = buildId
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent checks — compare
|
||||||
|
if (buildId !== initialBuildId) {
|
||||||
notified.current = true
|
notified.current = true
|
||||||
toast('A new version is available', {
|
toast('A new version is available', {
|
||||||
description: 'Refresh to get the latest updates.',
|
description: 'Refresh to get the latest updates.',
|
||||||
@@ -31,6 +42,9 @@ export function VersionGuard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initial check (captures build ID)
|
||||||
|
checkVersion()
|
||||||
|
|
||||||
// Check on tab focus (covers users returning to stale tabs)
|
// Check on tab focus (covers users returning to stale tabs)
|
||||||
window.addEventListener('focus', checkVersion)
|
window.addEventListener('focus', checkVersion)
|
||||||
|
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ export const userRouter = router({
|
|||||||
|
|
||||||
// For APPLICANT users, attach their project's current round info
|
// For APPLICANT users, attach their project's current round info
|
||||||
const applicantIds = users.filter((u) => u.role === 'APPLICANT').map((u) => u.id)
|
const applicantIds = users.filter((u) => u.role === 'APPLICANT').map((u) => u.id)
|
||||||
const applicantRoundMap = new Map<string, { roundName: string; state: string } | null>()
|
const applicantRoundMap = new Map<string, { projectName: string; roundName: string; state: string } | null>()
|
||||||
|
|
||||||
if (applicantIds.length > 0) {
|
if (applicantIds.length > 0) {
|
||||||
// Find each applicant's project, then the latest round state
|
// Find each applicant's project, then the latest round state
|
||||||
@@ -342,11 +342,13 @@ export const userRouter = router({
|
|||||||
if (!applicantIds.includes(uid)) continue
|
if (!applicantIds.includes(uid)) continue
|
||||||
if (latestTerminal && !latestActive) {
|
if (latestTerminal && !latestActive) {
|
||||||
applicantRoundMap.set(uid, {
|
applicantRoundMap.set(uid, {
|
||||||
|
projectName: proj.title,
|
||||||
roundName: latestTerminal.round.name,
|
roundName: latestTerminal.round.name,
|
||||||
state: latestTerminal.state,
|
state: latestTerminal.state,
|
||||||
})
|
})
|
||||||
} else if (latest) {
|
} else if (latest) {
|
||||||
applicantRoundMap.set(uid, {
|
applicantRoundMap.set(uid, {
|
||||||
|
projectName: proj.title,
|
||||||
roundName: latest.round.name,
|
roundName: latest.round.name,
|
||||||
state: latest.state,
|
state: latest.state,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user