Skip to content

Commit 318f9f9

Browse files
authored
Merge pull request #250 from coder13/fix/solve-history-user-preview
Enable user-scoped solve history preview
2 parents f7b5b46 + c63b123 commit 318f9f9

26 files changed

Lines changed: 1071 additions & 732 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ ROOM_RECONNECT_GRACE_MS=60000
88
GRAND_PRIX_ENABLED=false
99
# Friend System routes remain disabled through the #188 launch gate.
1010
SOCIAL_FEATURES_ENABLED=false
11+
# Comma-separated WCA user IDs approved for the solve-history preview.
12+
FEATURE_SOLVE_HISTORY_USER_IDS=
1113
# Manual Compose commands use this tag. scripts/deploy.sh overrides it with
1214
# the full deployed commit SHA.
1315
APP_IMAGE_TAG=local

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,17 +104,20 @@ access codes, OAuth credentials, chat content, scramble text, or solve times.
104104

105105
New MongoDB writes are mirrored into PostgreSQL without changing application
106106
reads. PostgreSQL receives public identity and preferences, rooms and
107-
participant state, attempts, durable solve results, and sanitized analytics
108-
events. OAuth access tokens are deliberately not copied. Writes use
107+
participant state, RaceSession projections for normal rooms, attempts, durable
108+
solve results, and sanitized analytics events. OAuth access tokens are deliberately not copied. Writes use
109109
deterministic UUIDs and upserts,
110110
so retries and future backfills are idempotent. Live room saves mirror only the
111111
attempts and results changed by that save; complete room snapshots are reserved
112-
for explicit backfills. Changing a room event explicitly replaces that room's
113-
PostgreSQL attempts so removed MongoDB attempts do not remain queryable.
112+
for explicit backfills. Changing a normal room event ends its projected
113+
RaceSession and starts a new one; earlier attempts and solves remain preserved
114+
under their earlier session.
114115

115116
Solve penalties use dedicated boolean columns rather than JSON so histories and
116-
statistics remain compact and index-friendly. User solve history is indexed by
117-
creation time and solve ID for stable cursor pagination.
117+
statistics remain compact and index-friendly. `GET /api/solve-history` reads
118+
PostgreSQL session-linked history for the authenticated participant. It is
119+
enabled in development and may be enabled for selected production users with
120+
`FEATURE_SOLVE_HISTORY_USER_IDS`.
118121

119122
Set `POSTGRES_ENABLED=false` to disable mirroring. Production should set
120123
`PGHOST`, `PGDATABASE`, `PGUSER`, and `POSTGRES_PASSWORD`, or provide a

client/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
"@mui/icons-material": "^6.5.0",
99
"@mui/material": "^6.5.0",
1010
"@mui/styles": "^6.5.0",
11-
"clsx": "1.1.0",
12-
"date-fns": "^2.16.1",
11+
"clsx": "2.1.1",
12+
"date-fns": "^4.4.0",
1313
"history": "4.10.1",
1414
"material-ui-confirm": "^4.0.0",
1515
"prop-types": "15.8.1",
@@ -19,7 +19,7 @@
1919
"react-dom": "18.3.1",
2020
"react-ga4": "^3.0.1",
2121
"react-markdown": "6.0.3",
22-
"react-redux": "^8.1.3",
22+
"react-redux": "^9.3.0",
2323
"react-router-dom": "^5.3.4",
2424
"react-twitch-embed": "^3.0.2",
2525
"react-virtualized": "^9.22.6",

client/src/components/Room/Normal/index.jsx

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import clsx from 'clsx';
33
import PropTypes from 'prop-types';
44
import { makeStyles } from '@mui/styles';
55
import { connect } from 'react-redux';
6-
import Grid from '@mui/material/Grid';
76
import Paper from '@mui/material/Paper';
87
import BottomNavigation from '@mui/material/BottomNavigation';
98
import BottomNavigationAction from '@mui/material/BottomNavigationAction';
@@ -46,12 +45,30 @@ const useStyles = makeStyles((theme) => ({
4645
},
4746
},
4847
container: {
48+
display: 'flex',
4949
flexGrow: 1,
5050
flexWrap: 'nowrap',
51+
minHeight: 0,
5152
},
52-
panel: {
53-
flexGrow: 1,
54-
transition: `display 5s ${theme.transitions.easing.easeInOut}`,
53+
mainPanel: {
54+
display: 'flex',
55+
flex: '1 1 auto',
56+
minWidth: 0,
57+
},
58+
chatPanel: {
59+
display: 'flex',
60+
flex: '0 0 24rem',
61+
minWidth: 0,
62+
transition: theme.transitions.create('flex-basis'),
63+
[theme.breakpoints.down('sm')]: {
64+
flexBasis: '100%',
65+
},
66+
},
67+
chatPanelClosed: {
68+
flexBasis: '3rem',
69+
[theme.breakpoints.down('sm')]: {
70+
flexBasis: '100%',
71+
},
5572
},
5673
backdrop: {
5774
zIndex: theme.zIndex.drawer + 1,
@@ -73,6 +90,7 @@ const panels = [{
7390
export const NormalRoom = ({ room, user }) => {
7491
const classes = useStyles();
7592
const [currentPanel, setCurrentPanel] = useState(0);
93+
const [chatOpen, setChatOpen] = useState(true);
7694

7795
const isAdmin = () => room.admin && room.admin.id === user.id;
7896

@@ -91,27 +109,23 @@ export const NormalRoom = ({ room, user }) => {
91109
</Paper>
92110
<Divider />
93111

94-
<Grid container direction="row" className={classes.container}>
95-
<Grid
96-
item
97-
className={clsx(classes.panel, {
112+
<div className={classes.container}>
113+
<div
114+
className={clsx(classes.mainPanel, {
98115
[classes.hiddenOnMobile]: currentPanel !== 0,
99116
})}
100-
style={{
101-
flexGrow: 1,
102-
}}
103117
>
104118
<Main />
105-
</Grid>
106-
<Grid
107-
item
108-
className={clsx(classes.panel, {
119+
</div>
120+
<div
121+
className={clsx(classes.chatPanel, {
109122
[classes.hiddenOnMobile]: currentPanel !== 1,
123+
[classes.chatPanelClosed]: !chatOpen,
110124
})}
111125
>
112-
<Chat />
113-
</Grid>
114-
</Grid>
126+
<Chat open={chatOpen} onToggle={() => setChatOpen((isOpen) => !isOpen)} />
127+
</div>
128+
</div>
115129

116130
<BottomNavigation
117131
value={currentPanel}

client/src/components/Room/Panels/Chat.jsx

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,10 @@ const Icons = {
3333
const useStyles = makeStyles((theme) => ({
3434
root: {
3535
display: 'flex',
36-
flexGrow: 0,
37-
flexShrink: 1,
38-
width: '20rem',
36+
flexGrow: 1,
37+
minWidth: 0,
3938
flexDirection: 'column',
4039
height: '100%',
41-
[theme.breakpoints.down('sm')]: {
42-
width: '100%',
43-
flexGrow: 1,
44-
},
45-
},
46-
closed: {
47-
flexGrow: 0,
48-
width: 0,
49-
position: 'relative',
50-
border: 'none',
5140
},
5241
messages: {
5342
display: 'flex',
@@ -85,14 +74,8 @@ const useStyles = makeStyles((theme) => ({
8574
'user-select': 'text',
8675
},
8776
toolbarClosed: {
88-
position: 'absolute',
89-
width: '10em',
90-
right: 0,
91-
},
92-
hiddenOnMobile: {
93-
[theme.breakpoints.down('sm')]: {
94-
display: 'none',
95-
},
77+
justifyContent: 'center',
78+
padding: 0,
9679
},
9780
}));
9881

@@ -105,11 +88,10 @@ const UNKOWN_USER = {
10588
};
10689

10790
function Chat({
108-
dispatch, messages, users, user,
91+
dispatch, messages, users, user, open, onToggle,
10992
}) {
11093
const classes = useStyles();
11194
const [message, setMessage] = useState('');
112-
const [open, setOpen] = useState(true);
11395
const listRef = useRef();
11496

11597
const findUser = (id) => {
@@ -157,22 +139,24 @@ function Chat({
157139

158140
return (
159141
<Panel
160-
className={clsx(classes.root, {
161-
[classes.closed]: !open,
162-
})}
142+
className={classes.root}
163143
toolbar={(
164144
<Toolbar
165145
variant="dense"
166146
className={clsx({
167147
[classes.toolbarClosed]: !open,
168148
})}
169149
>
170-
<Typography variant="h6" style={{ flexGrow: 1 }}>
171-
Chat
172-
</Typography>
173-
<IconButton className={classes.hiddenOnMobile} onClick={() => setOpen(!open)}>
174-
{open ? <ArrowForwardIcon /> : <ArrowBackIcon /> }
175-
</IconButton>
150+
{open && (
151+
<Typography variant="h6" style={{ flexGrow: 1 }}>
152+
Chat
153+
</Typography>
154+
)}
155+
{onToggle && (
156+
<IconButton onClick={onToggle} aria-label={open ? 'Close chat' : 'Open chat'}>
157+
{open ? <ArrowForwardIcon /> : <ArrowBackIcon /> }
158+
</IconButton>
159+
)}
176160
</Toolbar>
177161
)}
178162
>
@@ -278,6 +262,8 @@ Chat.propTypes = {
278262
id: PropTypes.number,
279263
canJoinRoom: PropTypes.bool,
280264
}),
265+
open: PropTypes.bool,
266+
onToggle: PropTypes.func,
281267
};
282268

283269
Chat.defaultProps = {
@@ -287,6 +273,8 @@ Chat.defaultProps = {
287273
id: undefined,
288274
canJoinRoom: false,
289275
},
276+
open: true,
277+
onToggle: undefined,
290278
};
291279

292280
const mapStateToProps = (state) => ({

compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ x-app-environment: &app-environment
77
ROOM_RECONNECT_GRACE_MS: ${ROOM_RECONNECT_GRACE_MS:-60000}
88
GRAND_PRIX_ENABLED: ${GRAND_PRIX_ENABLED:-false}
99
SOCIAL_FEATURES_ENABLED: ${SOCIAL_FEATURES_ENABLED:-false}
10+
FEATURE_SOLVE_HISTORY_USER_IDS: ${FEATURE_SOLVE_HISTORY_USER_IDS:-}
1011
METRICS_ENABLED: ${METRICS_ENABLED:-true}
1112
METRICS_RETENTION_DAYS: ${METRICS_RETENTION_DAYS:-90}
1213
METRICS_HASH_SECRET: ${METRICS_HASH_SECRET:-}

docs/data.md

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,37 @@ Prisma defines PostgreSQL in `server/prisma/schema.prisma`, using two schemas:
2626

2727
| Schema | Contents |
2828
| --- | --- |
29-
| `app` | users, rooms, participants, attempts, and solves |
29+
| `app` | users, rooms, room/session participants, RaceSessions, attempts, and solves |
3030
| `analytics` | pseudonymous metric events |
3131

3232
PostgreSQL receives normalized copies of users/preferences, rooms and
33-
participant state, attempts, durable solves, and sanitized analytics. OAuth
34-
access tokens are deliberately not mirrored.
33+
participant state, normal-room RaceSession projections, attempts, durable
34+
solves, and sanitized analytics. OAuth access tokens are deliberately not
35+
mirrored. Existing PostgreSQL attempts and solves that predate the expansion
36+
remain unlinked until the backfill/reconciliation operation assigns them.
3537

36-
Application reads do not use PostgreSQL yet. Setting `POSTGRES_ENABLED=false`
37-
disables initialization and mirrors. PostgreSQL failures are logged, while API
38-
and Socket.IO health report the service as `degraded` instead of unavailable.
38+
Application reads remain MongoDB-backed except for the development-only solve
39+
history experiment. Setting `POSTGRES_ENABLED=false` disables initialization
40+
and mirrors. PostgreSQL failures are logged, while API and Socket.IO health
41+
report the service as `degraded` instead of unavailable.
3942

4043
## Dual-Write Guarantees
4144

4245
`server/postgres/dualWrite.js` derives stable UUIDs from Mongo/WCA identifiers
4346
and uses upserts so retries and future backfills remain idempotent.
4447

45-
Room saves collect changed attempts/results and mirror only that delta. An
46-
explicit event change replaces that room's PostgreSQL attempts so attempts
47-
removed from MongoDB do not remain queryable. Complete snapshots are reserved
48-
for explicit backfill behavior.
48+
Room saves collect changed attempts/results and mirror only that delta. A
49+
normal-room event change ends the current projected RaceSession and creates a
50+
new one, preserving the earlier session's attempts and solves. Complete
51+
snapshots are reserved for explicit backfill behavior.
52+
53+
`GET /api/solve-history` is a PostgreSQL-only, authenticated self-history
54+
endpoint enabled in development and optionally for selected production users
55+
through `FEATURE_SOLVE_HISTORY_USER_IDS`. It returns only session-linked solves
56+
for a current non-banned room participant; hidden, expired, and soft-deleted
57+
rooms remain readable to that participant. It never falls back to MongoDB or
58+
selects access codes, passwords, membership lists, moderation data, or email
59+
data.
4960

5061
The mirror intentionally catches database failures and returns control to the
5162
MongoDB-backed request. Monitoring must surface mirror errors because users may

docs/development.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ environment variables. Important groups are documented in `.env.example`:
8181
- CORS origins and metric retention; and
8282
- production TLS and backup paths.
8383

84+
An otherwise disabled feature that opts into user targeting can be scoped to selected WCA users with
85+
`FEATURE_<FLAG>_USER_IDS`, a comma-separated allowlist of numeric user IDs.
86+
For example, `FEATURE_SOLVE_HISTORY_USER_IDS=8184` enables the solve-history
87+
preview only for that signed-in user while its global production flag remains off.
88+
8489
`.env.example` is a template, not a file automatically sourced by host Node
8590
processes. Compose only passes variables referenced in its service environment;
8691
export any additional server override explicitly or add it to the relevant

packages/scrambles/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
"test:ci": "yarn test"
2323
},
2424
"devDependencies": {
25-
"eslint": "6.8.0",
26-
"eslint-config-airbnb-base": "14.1.0",
27-
"eslint-plugin-import": "2.20.2",
25+
"eslint": "8.57.0",
26+
"eslint-config-airbnb-base": "15.0.0",
27+
"eslint-plugin-import": "2.32.0",
2828
"jest": "^30.4.2"
2929
}
3030
}

server/api.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const { updateUsername } = require('./username');
77
const createFriendsRouter = require('./api/friends');
88
const createNotificationsRouter = require('./api/notifications');
99
const createUsersRouter = require('./api/users');
10+
const { createSolveHistoryRouter } = require('./api/solveHistory');
1011
const { isFeatureEnabled } = require('./features');
1112
const { apiRateLimitOptions } = require('./middlewares/apiRateLimit');
1213

@@ -36,6 +37,8 @@ module.exports = (app) => {
3637
res.json(req.user.toObject());
3738
});
3839

40+
router.use('/solve-history', auth, createSolveHistoryRouter());
41+
3942
router.put('/updateUsername', auth, async (req, res) => {
4043
try {
4144
const user = await updateUsername(User, req.user, req.body.username);

0 commit comments

Comments
 (0)