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
15 changes: 13 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# Random string used to authenticate kiosk display pages (e.g. display_chambers_2026)
DISPLAY_KEY=
# Slack app credentials (from the Slack app's Basic Information page)
# Slack app credentials (from the Slack app's Basic Information page).
#
# The bot token needs chat:write to post committee meeting reminders, and the
# bot has to be invited to each committee's channel -- Slack answers a post to a
# channel it is not in with ok:false, not an HTTP error, so this fails quietly.
# Add chat:write.public if you would rather not invite it to each one.
SLACK_BOT_TOKEN=
SLACK_SIGNING_SECRET=

Expand All @@ -20,5 +25,11 @@ SLACK_SIGNING_SECRET=
# it will send anything. Spell it exactly -- a misnamed variable reads as unset.
CSC_EMAIL=

# Shared secret for the scheduled warm-up route (/api/cron/warm).
# Shared secret for the scheduled routes, /api/cron/warm and
# /api/cron/slack-reminders. Both are driven by GitHub Actions, which send it as
# a bearer token, so it must also be set as a CRON_SECRET repo secret.
#
# /api/cron/warm only does trivial reads and still answers without it.
# /api/cron/slack-reminders writes and posts to Slack, so it refuses to run at
# all unless this is set.
CRON_SECRET=
36 changes: 36 additions & 0 deletions .github/workflows/slack-reminders.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: slack-reminders

# Hits /api/cron/slack-reminders on production, which posts tomorrow's committee
# meetings to each committee's Slack channel (issue #95).
#
# A GitHub Action rather than a Vercel cron, for the same reason keep-warm.yml is
# one: Hobby plans cap Vercel crons at once per day, and this needs to retry.
#
# The schedule fires five times because GitHub schedules lag and are occasionally
# skipped altogether, and a reminder that silently never arrives is the failure
# this feature is most likely to have. The endpoint is idempotent -- it posts
# nothing before 9am Eastern, and records each reminder it sends so a later run
# the same morning finds the work already done -- so the extra firings cost a
# no-op request each.
#
# 13:00-17:00 UTC is 9am-1pm Eastern in EDT, 8am-noon in EST. The endpoint's own
# 9am-Eastern gate is what makes that offset harmless: under EST the 13:00 run
# declines to post and the 14:00 one does it.
#
# Needs the CRON_SECRET repo secret to match the CRON_SECRET env var on Vercel.
# Unlike /api/cron/warm, this endpoint refuses to run at all without it.

on:
schedule:
- cron: '0 13,14,15,16,17 * * *'
workflow_dispatch:

jobs:
post:
runs-on: ubuntu-latest
steps:
- name: Post tomorrow's committee meetings
run: |
curl -fsS -m 60 --retry 2 \
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
https://chambers.northeasternsga.com/api/cron/slack-reminders
2 changes: 1 addition & 1 deletion app/(dashboard)/dashboard-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ export default function DashboardShell({
<span className="text-[#c8102e] font-bold text-xl tracking-tight">Chambers</span>
</div>
<p className="text-slate-500 text-xs mt-0.5">NU Student Gov. Association</p>
<p className="text-slate-600 text-xs mt-1">v1.14.0</p>
<p className="text-slate-600 text-xs mt-1">v1.14.1</p>
{userName && (
<div className="flex items-start justify-between mt-2">
<p className="text-slate-500 text-xs italic">{getGreeting()},<br />{userName}</p>
Expand Down
110 changes: 104 additions & 6 deletions app/(dashboard)/management/bodies-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useState } from 'react'
import { Skeleton } from '@/app/_components/skeleton'
import { BODY_TYPES, bodyTypeGetsSlackReminders, type BodyType } from '@/lib/body-types'

function BodiesTabSkeleton() {
return (
Expand Down Expand Up @@ -43,16 +44,28 @@ interface Body {
division: string
is_active: boolean
body_open: boolean
body_type: BodyType
slack_channel_id: string | null
slack_reminders_enabled: boolean
}

export default function BodiesTab() {
const [bodies, setBodies] = useState<Body[]>([])
const [loading, setLoading] = useState(true)
const [showCreateForm, setShowCreateForm] = useState(false)
const [newBody, setNewBody] = useState({ name: '', division: '' })
const [newBody, setNewBody] = useState<{ name: string; division: string; body_type: BodyType }>(
{ name: '', division: '', body_type: 'Other' }
)
const [creating, setCreating] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editValues, setEditValues] = useState({ name: '', division: '', is_active: true, body_open: false })
const [editValues, setEditValues] = useState<{
name: string; division: string; is_active: boolean; body_open: boolean
body_type: BodyType; slack_channel_id: string; slack_reminders_enabled: boolean
}>({
name: '', division: '', is_active: true, body_open: false,
body_type: 'Other', slack_channel_id: '', slack_reminders_enabled: true,
})
const [saveError, setSaveError] = useState<string | null>(null)

const fetchBodies = async () => {
const res = await fetch('/api/administrator/bodies')
Expand All @@ -72,25 +85,42 @@ export default function BodiesTab() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newBody),
})
setNewBody({ name: '', division: '' })
setNewBody({ name: '', division: '', body_type: 'Other' })
setShowCreateForm(false)
await fetchBodies()
setCreating(false)
}

const saveEdit = async (id: string) => {
await fetch('/api/administrator/bodies', {
setSaveError(null)
const res = await fetch('/api/administrator/bodies', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...editValues }),
})
// The channel ID is the one field here that can be rejected, and a silent
// failure would leave the row looking saved and reminders never arriving.
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setSaveError(data.error ?? 'Could not save that body.')
return
}
setEditingId(null)
await fetchBodies()
}

const startEdit = (body: Body) => {
setEditingId(body.id)
setEditValues({ name: body.name, division: body.division, is_active: body.is_active, body_open: body.body_open ?? false })
setSaveError(null)
setEditValues({
name: body.name,
division: body.division,
is_active: body.is_active,
body_open: body.body_open ?? false,
body_type: body.body_type ?? 'Other',
slack_channel_id: body.slack_channel_id ?? '',
slack_reminders_enabled: body.slack_reminders_enabled ?? true,
})
}

if (loading) return <BodiesTabSkeleton />
Expand Down Expand Up @@ -130,6 +160,15 @@ export default function BodiesTab() {
<option key={d} value={d}>{d}</option>
))}
</select>
<select
value={newBody.body_type}
onChange={e => setNewBody({ ...newBody, body_type: e.target.value as BodyType })}
className={inputCls}
>
{BODY_TYPES.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
<div className="flex gap-2">
<button
onClick={createBody}
Expand Down Expand Up @@ -172,6 +211,55 @@ export default function BodiesTab() {
<option key={d} value={d}>{d}</option>
))}
</select>
<select
value={editValues.body_type}
onChange={e => setEditValues({ ...editValues, body_type: e.target.value as BodyType })}
className={inputCls}
>
{BODY_TYPES.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>

{/* Slack reminders. Shown only for the types the bot posts for,
rather than sitting inert on every board and working group. */}
{bodyTypeGetsSlackReminders(editValues.body_type) && (
<div className="space-y-2 border border-[#1e5080] rounded-lg p-3">
<div>
<label htmlFor={`slack-${b.id}`} className="block text-xs font-medium text-[#93b8d8] mb-1">
Slack channel ID
</label>
<input
id={`slack-${b.id}`}
type="text"
placeholder="C0123ABCDEF"
value={editValues.slack_channel_id}
onChange={e => setEditValues({ ...editValues, slack_channel_id: e.target.value })}
className={inputCls}
/>
<p className="text-xs text-[#6a96bb] mt-1">
In Slack, open the channel, choose View channel details, and copy the ID at the bottom.
Invite the Chambers bot to the channel or it cannot post. Leave blank for no reminders.
</p>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={editValues.slack_reminders_enabled}
onChange={e => setEditValues({ ...editValues, slack_reminders_enabled: e.target.checked })}
id={`reminders-${b.id}`}
/>
<label htmlFor={`reminders-${b.id}`} className="text-sm text-[#f0f6ff]">
Post meeting reminders the day before
</label>
</div>
<p className="text-xs text-[#6a96bb]">
Leadership of this committee can also turn this off themselves with
{' '}<code className="text-[#93b8d8]">/chambers-reminders off</code> in the channel.
</p>
</div>
)}

<div className="flex items-center gap-2">
<input
type="checkbox"
Expand All @@ -190,6 +278,9 @@ export default function BodiesTab() {
/>
<label htmlFor={`open-${b.id}`} className="text-sm text-[#f0f6ff]">Open for self-signup</label>
</div>
{saveError && (
<p className="text-sm text-[#f87171]">{saveError}</p>
)}
<div className="flex gap-2">
<button
onClick={() => saveEdit(b.id)}
Expand All @@ -209,9 +300,16 @@ export default function BodiesTab() {
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-[#f0f6ff]">{b.name}</p>
<p className="text-sm text-[#93b8d8]">{b.division}</p>
<p className="text-sm text-[#93b8d8]">{b.division} · {b.body_type}</p>
</div>
<div className="flex items-center gap-2">
{/* Only ever shown for a committee that could actually be
posted to, so its absence is not ambiguous. */}
{bodyTypeGetsSlackReminders(b.body_type) && b.slack_channel_id && (
<span className={`text-xs px-2 py-1 rounded-full font-medium ${b.slack_reminders_enabled ? 'bg-[#062f3b] text-[#22d3ee]' : 'bg-[#1e3a5f] text-[#93b8d8]'}`}>
{b.slack_reminders_enabled ? 'Slack on' : 'Slack off'}
</span>
)}
<span className={`text-xs px-2 py-1 rounded-full font-medium ${b.is_active ? 'bg-[#0f3d20] text-[#4ade80]' : 'bg-[#3d0f0f] text-[#f87171]'}`}>
{b.is_active ? 'Active' : 'Inactive'}
</span>
Expand Down
5 changes: 4 additions & 1 deletion app/(dashboard)/my-rooms/my-rooms-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import BookingDetailModal from './booking-detail-modal'
import NotificationBell from './notification-bell'
import CalendarView from './calendar-view'
import { Skeleton } from '@/app/_components/skeleton'
import { wantsSenateSession } from '@/lib/senate-types'
import {
type FlatBooking,
type MyRoomsResponse,
Expand Down Expand Up @@ -136,8 +137,10 @@ export default function MyRoomsClient({
return () => window.removeEventListener('chambers:senate-prefs-updated', fetchBookings)
}, [fetchBookings])

// The same rule the update emails and dashboard alerts now apply, so what this
// page hides and what Chambers stops sending you cannot drift apart (#92, #93).
const passesSenateFilter = (b: FlatBooking) =>
b.bodyName !== 'Senate' || !b.senateType || (senateTypePreferences[b.senateType] ?? true)
wantsSenateSession(senateTypePreferences, b.bodyName, b.senateType)

const filteredUpcoming = all.filter(b => isWithinDays(b.date, filter, today) && passesSenateFilter(b))

Expand Down
19 changes: 3 additions & 16 deletions app/(dashboard)/my-rooms/shared.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatScopeLabel, type BookingScope, type Division } from '@/lib/booking-scope'
import { APP_TIME_ZONE } from '@/lib/app-zone'

export interface FlatBooking {
id: string
Expand Down Expand Up @@ -99,8 +100,6 @@ export function bookingTitle(b: FlatBooking): string {
return b.purpose?.trim() || b.scopeLabel
}

export const SENATE_TYPES = ['Full Body', 'Weekly', 'Office Hours'] as const

export const statusColors: Record<string, string> = {
'Reserved': 'bg-[#0f3d20] border-[#22c55e]',
'Alternate Room': 'bg-[#0e2f4f] border-[#4285f4]',
Expand Down Expand Up @@ -152,20 +151,8 @@ export const senateTypeBadgeColors: Record<string, string> = {
}
export const DEFAULT_SENATE_BADGE = 'bg-[#1e3a5f] text-[#93b8d8] border border-[#2d5f8f]/40'

/**
* The timezone every booking date is expressed in.
*
* `booking_date`, `occurrence_date` and `session_date` are all DATE columns --
* no time, no offset. They mean a calendar day in Boston, because that is where
* the rooms are. "Today" therefore has to mean Boston's today, not the server's
* and not the viewer's: a student on co-op in California at 10pm PT is still
* looking at Northeastern's schedule, and should see the same day their peers on
* campus see.
*
* Pinning it also makes the value reproducible, which is what lets this page be
* server-rendered at all -- see todayInAppZone().
*/
export const APP_TIME_ZONE = 'America/New_York'
// Re-exported so this module stays the one import for My Rooms' date helpers.
export { APP_TIME_ZONE } from '@/lib/app-zone'

/**
* Today's date in APP_TIME_ZONE, as 'YYYY-MM-DD'.
Expand Down
10 changes: 8 additions & 2 deletions app/(dashboard)/settings-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { getJson } from '@/lib/fetch-json'
import { Skeleton } from '@/app/_components/skeleton'
import { getPrefsForRole, EMAIL_PREF_LABELS, EmailPrefKey } from '@/lib/email-preferences'
import { SENATE_TYPES } from '@/lib/senate-types'

interface Membership {
id: string
Expand Down Expand Up @@ -34,7 +35,7 @@ export interface Settings {
available_bodies: AvailableBody[]
}

export const SENATE_TYPES = ['Full Body', 'Weekly', 'Office Hours'] as const
export { SENATE_TYPES } from '@/lib/senate-types'

interface SettingsModalProps {
onClose: () => void
Expand Down Expand Up @@ -273,7 +274,12 @@ export default function SettingsModal({ onClose, cachedSettings, onSettingsLoade
{/* Senate Session Types */}
{!loading && isSenateMember && (
<div className="space-y-3">
<p className="text-xs font-medium text-[#93b8d8]">Senate Session Types Shown in My Rooms</p>
<div>
<p className="text-xs font-medium text-[#93b8d8]">Senate Session Types You Follow</p>
<p className="text-xs text-[#6a96bb] mt-1">
Deselected types are hidden from My Rooms, and Chambers stops emailing and alerting you about them.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SENATE_TYPES.map(type => (
<label key={type} className="flex items-center gap-3 cursor-pointer">
Expand Down
1 change: 1 addition & 0 deletions app/(dashboard)/sga-spaces/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ export default function SGASpacesPage() {
editBookingId={editBooking.id}
initialTitle={editBooking.title}
initialAttendees={editBooking.attendees}
minHoursAdvance={minHoursAdvance}
onClose={() => setEditBooking(null)}
onSuccess={() => {
setEditBooking(null)
Expand Down
Loading
Loading