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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Moderation dashboard stats panel with live totals for total cases, active cases, warnings, and bans using `/api/guilds/:guildId/moderation/stats`.
- Case detail modal now supports direct case deactivation for active cases via `/api/guilds/:guildId/moderation/cases/:caseId/deactivate`.

### Changed

- Privacy Policy expanded from placeholder copy to production-grade coverage for scope, third-party integrations, retention, user rights, security, and policy updates.
- Terms of Service expanded from placeholder copy to production-grade coverage for acceptable use, third-party dependencies, suspension/termination, disclaimers, and change policy.

## [2.6.34] - 2026-03-16

### Added
Expand Down
24 changes: 22 additions & 2 deletions packages/backend/src/routes/starboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { requireAuth, type AuthenticatedRequest } from '../middleware/auth'
import { validateBody, validateParams } from '../middleware/validate'
import { writeLimiter } from '../middleware/rateLimit'
import { asyncHandler } from '../middleware/asyncHandler'
import { AppError } from '../errors/AppError'
import { z } from 'zod'
import { starboardService } from '@lucky/shared/services'

Expand Down Expand Up @@ -40,7 +41,23 @@ export function setupStarboardRoutes(app: Express): void {
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const guildId = p(req.params.guildId)
const data = upsertConfigBody.parse(req.body)
const config = await starboardService.upsertConfig(guildId, data)

let channelId = data.channelId
if (!channelId) {
const existing = await starboardService.getConfig(guildId)
channelId = existing?.channelId
}

if (!channelId) {
throw AppError.badRequest(
'channelId is required when creating starboard config',
)
}

const config = await starboardService.upsertConfig(guildId, {
...data,
channelId,
})
res.json({ config })
}),
)
Expand All @@ -64,7 +81,10 @@ export function setupStarboardRoutes(app: Express): void {
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const guildId = p(req.params.guildId)
const limit = Number(req.query.limit) || 10
const entries = await starboardService.getTopEntries(guildId, Math.min(limit, 50))
const entries = await starboardService.getTopEntries(
guildId,
Math.min(limit, 50),
)
res.json({ entries })
}),
)
Expand Down
154 changes: 153 additions & 1 deletion packages/frontend/src/pages/Moderation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Hash,
User,
Calendar,
BarChart3,
} from 'lucide-react'
import Card from '@/components/ui/Card'
import Button from '@/components/ui/Button'
Expand All @@ -37,7 +38,8 @@
import { api } from '@/services/api'
import { useGuildStore } from '@/stores/guildStore'
import { cn } from '@/lib/utils'
import type { ModerationCase } from '@/types'
import { toast } from 'sonner'
import type { ModerationCase, ModerationStats } from '@/types'

const ACTION_STYLES: Record<
string,
Expand Down Expand Up @@ -115,15 +117,56 @@
return formatDate(dateStr)
}

function StatCard({
label,
value,
icon: Icon,
tone,
}: {
label: string
value: number
icon: React.ComponentType<{ className?: string }>
tone: 'blue' | 'green' | 'yellow' | 'red'
}) {

Check warning on line 130 in packages/frontend/src/pages/Moderation.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZ0CPH0SOXZvjmSzU2tN&open=AZ0CPH0SOXZvjmSzU2tN&pullRequest=334
const toneClass: Record<typeof tone, string> = {
blue: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
green: 'bg-green-500/10 text-green-400 border-green-500/20',
yellow: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
red: 'bg-red-500/10 text-red-400 border-red-500/20',
}

return (
<Card className='p-4'>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='text-xs text-lucky-text-tertiary uppercase tracking-wide'>
{label}
</p>
<p className='text-2xl font-semibold text-white mt-1'>
{value.toLocaleString()}
</p>
</div>
<div className={cn('p-2 rounded-lg border', toneClass[tone])}>
<Icon className='w-4 h-4' />
</div>
</div>
</Card>
)
}

function CaseDetailModal({
caseData,
open,
onClose,
onDeactivate,
deactivating,
}: {
caseData: ModerationCase | null
open: boolean
onClose: () => void
onDeactivate: (caseId: string) => Promise<void>
deactivating: boolean
}) {

Check warning on line 169 in packages/frontend/src/pages/Moderation.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZ0CPH0SOXZvjmSzU2tO&open=AZ0CPH0SOXZvjmSzU2tO&pullRequest=334
if (!caseData) return null
const style = ACTION_STYLES[caseData.type] || ACTION_STYLES.warn
const ActionIcon = ACTION_ICONS[caseData.type] || Shield
Expand Down Expand Up @@ -224,6 +267,28 @@
</div>
)}
</div>
<div className='flex justify-end gap-2 pt-2 border-t border-lucky-border'>
<Button
size='sm'
variant='ghost'
onClick={onClose}
disabled={deactivating}
>
Close
</Button>
{caseData.active && (
<Button
size='sm'
variant='destructive'
onClick={() => onDeactivate(caseData.id)}
disabled={deactivating}
>
{deactivating
? 'Deactivating...'
: 'Deactivate Case'}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
Expand All @@ -234,6 +299,11 @@
const { selectedGuild } = useGuildStore()
const [cases, setCases] = useState<ModerationCase[]>([])
const [total, setTotal] = useState(0)
const [stats, setStats] = useState<ModerationStats | null>(null)
const [statsLoading, setStatsLoading] = useState(true)
const [deactivatingCaseId, setDeactivatingCaseId] = useState<string | null>(
null,
)
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [typeFilter, setTypeFilter] = useState<string>('all')
Expand Down Expand Up @@ -272,9 +342,49 @@
}
}, [selectedGuild?.id, page, typeFilter, debouncedSearch])

const fetchStats = useCallback(async () => {
if (!selectedGuild?.id) return

setStatsLoading(true)
try {
const res = await api.moderation.getStats(selectedGuild.id)
setStats(res.data.stats)
} catch {
setStats(null)
} finally {
setStatsLoading(false)
}
}, [selectedGuild?.id])

const handleDeactivateCase = useCallback(
async (caseId: string) => {
if (!selectedGuild?.id) return

setDeactivatingCaseId(caseId)
try {
await api.moderation.deactivateCase(selectedGuild.id, caseId)
toast.success('Case deactivated')
await Promise.all([fetchCases(), fetchStats()])
setSelectedCase((prev) =>
prev ? { ...prev, active: false } : prev,
)
} catch {
toast.error('Failed to deactivate case')
} finally {
setDeactivatingCaseId(null)
}
},
[selectedGuild?.id, fetchCases, fetchStats],
)

useEffect(() => {
fetchCases()
}, [fetchCases])

useEffect(() => {
fetchStats()
}, [fetchStats])

useEffect(() => {
setPage(1)
}, [typeFilter, debouncedSearch])
Expand Down Expand Up @@ -306,6 +416,44 @@
</p>
</header>

{statsLoading ? (
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3'>
{Array.from({ length: 4 }).map((_, index) => (
<Card key={index} className='p-4'>

Check warning on line 422 in packages/frontend/src/pages/Moderation.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Array index in keys

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZ0CPH0SOXZvjmSzU2tP&open=AZ0CPH0SOXZvjmSzU2tP&pullRequest=334
<Skeleton className='h-3 w-20' />
<Skeleton className='h-8 w-16 mt-2' />
</Card>
))}
</div>
) : stats ? (
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3'>
<StatCard
label='Total Cases'
value={stats.totalCases}
icon={BarChart3}
tone='blue'
/>
<StatCard
label='Active Cases'
value={stats.activeCases}
icon={Shield}
tone='green'
/>
<StatCard
label='Warnings'
value={stats.casesByType.warn ?? 0}
icon={AlertTriangle}
tone='yellow'
/>
<StatCard
label='Bans'
value={stats.casesByType.ban ?? 0}
icon={Ban}
tone='red'
/>
</div>
) : null}

Check warning on line 455 in packages/frontend/src/pages/Moderation.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZ0CPH0SOXZvjmSzU2tQ&open=AZ0CPH0SOXZvjmSzU2tQ&pullRequest=334

{/* Filters */}
<Card className='p-4'>
<div className='flex flex-col sm:flex-row gap-3'>
Expand Down Expand Up @@ -533,6 +681,10 @@
caseData={selectedCase}
open={!!selectedCase}
onClose={() => setSelectedCase(null)}
onDeactivate={handleDeactivateCase}
deactivating={
!!selectedCase && deactivatingCaseId === selectedCase.id
}
/>
</div>
)
Expand Down
83 changes: 71 additions & 12 deletions packages/frontend/src/pages/PrivacyPolicy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,98 @@ export default function PrivacyPolicyPage() {
<p className='type-meta text-lucky-text-tertiary'>Legal</p>
<h1 className='type-h1'>Privacy Policy</h1>
<p className='type-body text-lucky-text-secondary'>
Last updated: March 11, 2026
Last updated: March 18, 2026
</p>
</header>

<section className='space-y-3'>
<h2 className='type-h2'>Scope and controller</h2>
<p className='type-body text-lucky-text-secondary'>
This policy explains how Lucky collects, uses, and
protects data when you use the Discord bot and web
dashboard. It applies to authentication, server
configuration, moderation features, music and
integrations, and operational security.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Data we collect</h2>
<p className='type-body text-lucky-text-secondary'>
Lucky processes Discord account identifiers and server
configuration data required to provide bot features and
dashboard access.
We process the minimum data needed to operate the
service: Discord account identifiers, guild/server IDs,
role and channel references, bot configuration data,
moderation case records, optional integration data
(Last.fm and Twitch), and session/security logs.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>How we use data</h2>
<p className='type-body text-lucky-text-secondary'>
Data is used to authenticate users, manage server
settings, and operate integrations requested by server
administrators.
Data is used to authenticate access, enforce role-based
permissions, persist server settings, run bot commands,
provide dashboard features, and protect platform
reliability and security.
</p>
<p className='type-body text-lucky-text-secondary'>
Lucky does not sell personal data.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Third-party services</h2>
<p className='type-body text-lucky-text-secondary'>
Lucky depends on third-party platforms to function,
including Discord (identity and guild context), and
optionally Last.fm and Twitch when you enable those
integrations. These providers operate under their own
terms and privacy policies.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Retention and deletion</h2>
<p className='type-body text-lucky-text-secondary'>
We retain data only as long as needed for operational,
security, and legal purposes. You can remove Lucky from
your server and disable integrations at any time.
Requests to delete personal data can be made through our
support channel below.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Your rights</h2>
<p className='type-body text-lucky-text-secondary'>
Subject to applicable law, you may request access,
correction, export, restriction, or deletion of personal
data associated with your account or server
configuration.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Security</h2>
<p className='type-body text-lucky-text-secondary'>
We use reasonable technical and organizational
safeguards to protect data. No system is perfectly
secure, and you remain responsible for protecting your
Discord account and server permissions.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Data sharing and retention</h2>
<h2 className='type-h2'>Policy updates</h2>
<p className='type-body text-lucky-text-secondary'>
Lucky does not sell personal data. Data is retained only
as needed for service operation, security, and legal
obligations.
We may update this policy when features, integrations,
or legal requirements change. Material updates are
published here with a revised date.
</p>
</section>

<section className='space-y-3'>
<h2 className='type-h2'>Contact</h2>
<h2 className='type-h2'>Contact and requests</h2>
<p className='type-body text-lucky-text-secondary'>
For privacy requests, open an issue at{' '}
<a
Expand Down
Loading
Loading