diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-20 23:32:22 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-20 23:32:22 +0900 |
| commit | 8e73d12a703e90ad908962143951178c13d0d6fe (patch) | |
| tree | 8bed43aa4b115f8bc50ed258aa192a94b6d2903e | |
| parent | aa07ba2e0a40b0097a4f9aee3c06dcbd9a749105 (diff) | |
| download | phperkaigi-2026-albatross-8e73d12a703e90ad908962143951178c13d0d6fe.tar.gz phperkaigi-2026-albatross-8e73d12a703e90ad908962143951178c13d0d6fe.tar.zst phperkaigi-2026-albatross-8e73d12a703e90ad908962143951178c13d0d6fe.zip | |
feat: add user submission history page
Allow users to view their own past submissions (code, size, status,
timestamp) for each game. Adds API endpoint, backend handler, SQL query,
and frontend page with expandable code display.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | backend/api/generated.go | 151 | ||||
| -rw-r--r-- | backend/api/handler.go | 38 | ||||
| -rw-r--r-- | backend/api/handler_test.go | 166 | ||||
| -rw-r--r-- | backend/api/handler_wrapper.go | 10 | ||||
| -rw-r--r-- | backend/db/querier.go | 1 | ||||
| -rw-r--r-- | backend/db/query.sql.go | 39 | ||||
| -rw-r--r-- | backend/query.sql | 5 | ||||
| -rw-r--r-- | frontend/app/App.tsx | 8 | ||||
| -rw-r--r-- | frontend/app/api/client.ts | 13 | ||||
| -rw-r--r-- | frontend/app/api/schema.d.ts | 75 | ||||
| -rw-r--r-- | frontend/app/pages/DashboardPage.tsx | 3 | ||||
| -rw-r--r-- | frontend/app/pages/SubmissionsPage.test.tsx | 17 | ||||
| -rw-r--r-- | frontend/app/pages/SubmissionsPage.tsx | 126 | ||||
| -rw-r--r-- | openapi/api-server.yaml | 64 | ||||
| -rw-r--r-- | typespec/api-server/models.tsp | 11 | ||||
| -rw-r--r-- | typespec/api-server/routes.tsp | 9 |
16 files changed, 702 insertions, 34 deletions
diff --git a/backend/api/generated.go b/backend/api/generated.go index a419123..aee6f3e 100644 --- a/backend/api/generated.go +++ b/backend/api/generated.go @@ -97,6 +97,16 @@ type RankingEntry struct { SubmittedAt int64 `json:"submitted_at"` } +// Submission defines model for Submission. +type Submission struct { + Code string `json:"code"` + CodeSize int `json:"code_size"` + CreatedAt int64 `json:"created_at"` + GameID int `json:"game_id"` + Status ExecutionStatus `json:"status"` + SubmissionID int `json:"submission_id"` +} + // Tournament defines model for Tournament. type Tournament struct { BracketSize int `json:"bracket_size"` @@ -177,6 +187,9 @@ type ServerInterface interface { // (GET /games/{game_id}/play/latest_state) GetGamePlayLatestState(ctx echo.Context, gameID int) error + // (GET /games/{game_id}/play/submissions) + GetGamePlaySubmissions(ctx echo.Context, gameID int) error + // (POST /games/{game_id}/play/submit) PostGamePlaySubmit(ctx echo.Context, gameID int) error @@ -261,6 +274,22 @@ func (w *ServerInterfaceWrapper) GetGamePlayLatestState(ctx echo.Context) error return err } +// GetGamePlaySubmissions converts echo context to params. +func (w *ServerInterfaceWrapper) GetGamePlaySubmissions(ctx echo.Context) error { + var err error + // ------------- Path parameter "game_id" ------------- + var gameID int + + err = runtime.BindStyledParameterWithOptions("simple", "game_id", ctx.Param("game_id"), &gameID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game_id: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetGamePlaySubmissions(ctx, gameID) + return err +} + // PostGamePlaySubmit converts echo context to params. func (w *ServerInterfaceWrapper) PostGamePlaySubmit(ctx echo.Context) error { var err error @@ -384,6 +413,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/games/:game_id", wrapper.GetGame) router.POST(baseURL+"/games/:game_id/play/code", wrapper.PostGamePlayCode) router.GET(baseURL+"/games/:game_id/play/latest_state", wrapper.GetGamePlayLatestState) + router.GET(baseURL+"/games/:game_id/play/submissions", wrapper.GetGamePlaySubmissions) router.POST(baseURL+"/games/:game_id/play/submit", wrapper.PostGamePlaySubmit) router.GET(baseURL+"/games/:game_id/watch/latest_states", wrapper.GetGameWatchLatestStates) router.GET(baseURL+"/games/:game_id/watch/ranking", wrapper.GetGameWatchRanking) @@ -566,6 +596,52 @@ func (response GetGamePlayLatestState404JSONResponse) VisitGetGamePlayLatestStat return json.NewEncoder(w).Encode(response) } +type GetGamePlaySubmissionsRequestObject struct { + GameID int `json:"game_id"` +} + +type GetGamePlaySubmissionsResponseObject interface { + VisitGetGamePlaySubmissionsResponse(w http.ResponseWriter) error +} + +type GetGamePlaySubmissions200JSONResponse struct { + Submissions []Submission `json:"submissions"` +} + +func (response GetGamePlaySubmissions200JSONResponse) VisitGetGamePlaySubmissionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetGamePlaySubmissions401JSONResponse Error + +func (response GetGamePlaySubmissions401JSONResponse) VisitGetGamePlaySubmissionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type GetGamePlaySubmissions403JSONResponse Error + +func (response GetGamePlaySubmissions403JSONResponse) VisitGetGamePlaySubmissionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetGamePlaySubmissions404JSONResponse Error + +func (response GetGamePlaySubmissions404JSONResponse) VisitGetGamePlaySubmissionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + type PostGamePlaySubmitRequestObject struct { GameID int `json:"game_id"` Body *PostGamePlaySubmitJSONRequestBody @@ -842,6 +918,9 @@ type StrictServerInterface interface { // (GET /games/{game_id}/play/latest_state) GetGamePlayLatestState(ctx context.Context, request GetGamePlayLatestStateRequestObject) (GetGamePlayLatestStateResponseObject, error) + // (GET /games/{game_id}/play/submissions) + GetGamePlaySubmissions(ctx context.Context, request GetGamePlaySubmissionsRequestObject) (GetGamePlaySubmissionsResponseObject, error) + // (POST /games/{game_id}/play/submit) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySubmitRequestObject) (PostGamePlaySubmitResponseObject, error) @@ -980,6 +1059,31 @@ func (sh *strictHandler) GetGamePlayLatestState(ctx echo.Context, gameID int) er return nil } +// GetGamePlaySubmissions operation middleware +func (sh *strictHandler) GetGamePlaySubmissions(ctx echo.Context, gameID int) error { + var request GetGamePlaySubmissionsRequestObject + + request.GameID = gameID + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetGamePlaySubmissions(ctx.Request().Context(), request.(GetGamePlaySubmissionsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetGamePlaySubmissions") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetGamePlaySubmissionsResponseObject); ok { + return validResponse.VisitGetGamePlaySubmissionsResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // PostGamePlaySubmit operation middleware func (sh *strictHandler) PostGamePlaySubmit(ctx echo.Context, gameID int) error { var request PostGamePlaySubmitRequestObject @@ -1164,29 +1268,30 @@ func (sh *strictHandler) GetTournament(ctx echo.Context, tournamentID int) error // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+xaTW/bOBP+KwLf96jaThPswbfsIigKpICxTbGHohAoaWKzlUgth4rrGv7vC5L6FiXL", - "SbqLpL454nA4H88zw4/sSSTSTHDgCslyTzDaQErNzxsphdQ/MikykIqB+ZwCIl2D/ql2GZAlQSUZX5PD", - "wScS/s6ZhJgsP1eCX/xSUIRfIVLk4JOb7xDlign+UVGVG73A81RP44ID8YnMOddafYJ5FAEi8clWCr4O", - "KMctSOITxVIQuSK+8YElEIAx2UzWg9XfjCuQnCbFh9qi0nSfvKMp9J2NGWYJ3QW8GO1Ni3NJtR8BQiR4", - "jA0hvegapJZa0xQCFo8M2s978n8J92RJ/jev0zIvcjLXJt5puYNPGAZZHiYsaugMhUiAcj2cUsYDbTlI", - "YxJTkOIx/Z/QGlSoo1LSnf47kyJMID02fVWIHXyCikoFcUCVw2WffH+zFm/qr79d9bBTBqwZnabTfjsz", - "jjzUZnei4YJjFdkGDi8eLvTUPFHMTnXC5pYqQKXnayQ7EBQCqgAjISHAPEyZquPC8yShYQJkqWQO/qQ4", - "aazHbiiaRSbotfkpWDeW0C5Ju0kylpTr+oOeVsu5Ir+qsdVhHmAkWaaXd3qbUL7Oizo0AZW3pXiN50FC", - "Ik2zBILBQCumkgn1r7FMOcdvudVwor3oSKBuG26XSM02mVawZffKidE/Kf/G+PqGK7nrB7p0cwA1tZqC", - "BROLSIVGR3w7PHhEfShsqcHXQdxgGO9ELnXJ4MpBVUmjb6ACZD8GLD/aDoArWWibVHJrc2x2HNU3pSra", - "PErlBz3TpZLnaSBFPtiuVKVigCOdbLTle7W5FdfW6nXAaj/HszYAYgQYIHOOUzHbccpM9K3mcZtsnHs2", - "jTZ9hkG4A3fztuC+mMq0QjwYYZwVeXuaxrejGgWyTm1ujJr0HsWWyfhghLaMc5CBTsPJKKw0l6Y0DK5i", - "70rqpwItJ+4CWSR4kFG1cY9iQOOUcXe2ExpCMqkAj4TCDg7Y5wC2jU01p0fZyuTSvn6wtFrG74VZ0HZE", - "cp2EVEmB6JU7bm8LoXe9ek988gASDWLIYnY5W2ijRQacZowsyeVsMVvoNFG1MTGfa/pYHoGp1TohZov3", - "PiZL8g7MpksXDgmYCY5W+O1iYZsaV0WNp1mWsMjMnH9Fi1gLeDdlp1dac2TolVfHThYHwtfa45C7DXh6", - "JqDyNhQ9c+6BGOKZXuRqcXGSY6MbO3MKcphwbU5aHkMv5zRXGyHZj2r9y39z/XshQxbHwGda7uAXeJjv", - "i6p6OIYMgyVJU1DmCPR5TzQBDb50DzJMaRwz6pRZ8tWO9KrOl2eH3DSgOYB1xtVTcKUXv/r5i+v4I8gH", - "kF5EORfKu2c89lSdFog9CShyGcEQ3Oe6Os/LvXom0IH8lbAn0VVCd3/Ys9nPpIAx/XcR756A/oEzluug", - "6YZ62+iDm5pnPrxSPiTm9iXA8uplrCFoVtjbGntT80L6Q+XbWKy7t1BdAlkl52bxS5HDXolMaxcfrey5", - "YZwbxmvlxJaqaNPqGEdPl3/pKY2egS+qaZhfNI7NtQdNVi2Jk7pJn0iO9nI+5P5qXJL2XWESi4o3iJdC", - "oIZrk66CWk8sx66ESuVnvrxWviRiba97h3det0bkubZFGUXcChk7759Puxrm5eVZofEJ26hHOvO0J5uX", - "zqoKQSJXRyFk//3lZW9hrcPp6AH+A5AzxJ454vV7Gc73rSfc0cv1xgv6lGbefRv+z1q6ar38T3tBH3lj", - "PHfv19m9D4d/AgAA///ukgFcEykAAA==", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/backend/api/handler.go b/backend/api/handler.go index 8d0d9be..57ae973 100644 --- a/backend/api/handler.go +++ b/backend/api/handler.go @@ -376,6 +376,44 @@ func (h *Handler) GetGameWatchRanking(ctx context.Context, request GetGameWatchR }, nil } +func (h *Handler) GetGamePlaySubmissions(ctx context.Context, request GetGamePlaySubmissionsRequestObject, user *db.User) (GetGamePlaySubmissionsResponseObject, error) { + gameID := request.GameID + + _, err := h.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return GetGamePlaySubmissions404JSONResponse{ + Message: "Game not found", + }, nil + } + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + rows, err := h.q.GetSubmissionsByGameIDAndUserID(ctx, db.GetSubmissionsByGameIDAndUserIDParams{ + GameID: int32(gameID), + UserID: user.UserID, + }) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + submissions := make([]Submission, len(rows)) + for i, row := range rows { + submissions[i] = Submission{ + SubmissionID: int(row.SubmissionID), + GameID: int(row.GameID), + Code: row.Code, + CodeSize: int(row.CodeSize), + Status: ExecutionStatus(row.Status), + CreatedAt: row.CreatedAt.Time.Unix(), + } + } + + return GetGamePlaySubmissions200JSONResponse{ + Submissions: submissions, + }, nil +} + func (h *Handler) PostGamePlayCode(ctx context.Context, request PostGamePlayCodeRequestObject, user *db.User) (PostGamePlayCodeResponseObject, error) { gameID := request.GameID diff --git a/backend/api/handler_test.go b/backend/api/handler_test.go index 2415710..a28d605 100644 --- a/backend/api/handler_test.go +++ b/backend/api/handler_test.go @@ -16,17 +16,18 @@ import ( // mockQuerier implements db.Querier for testing. type mockQuerier struct { db.Querier - getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) - listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) - listPublicGamesFunc func(ctx context.Context) ([]db.ListPublicGamesRow, error) - deleteSessionFunc func(ctx context.Context, sessionID string) error - getLatestStateFunc func(ctx context.Context, arg db.GetLatestStateParams) (db.GetLatestStateRow, error) - updateCodeFunc func(ctx context.Context, arg db.UpdateCodeParams) error - getRankingFunc func(ctx context.Context, gameID int32) ([]db.GetRankingRow, error) - getLatestStatesFunc func(ctx context.Context, gameID int32) ([]db.GetLatestStatesOfMainPlayersRow, error) - getTournamentByIDFunc func(ctx context.Context, tournamentID int32) (db.Tournament, error) - listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) - listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) + getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) + listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) + listPublicGamesFunc func(ctx context.Context) ([]db.ListPublicGamesRow, error) + deleteSessionFunc func(ctx context.Context, sessionID string) error + getLatestStateFunc func(ctx context.Context, arg db.GetLatestStateParams) (db.GetLatestStateRow, error) + updateCodeFunc func(ctx context.Context, arg db.UpdateCodeParams) error + getRankingFunc func(ctx context.Context, gameID int32) ([]db.GetRankingRow, error) + getLatestStatesFunc func(ctx context.Context, gameID int32) ([]db.GetLatestStatesOfMainPlayersRow, error) + getTournamentByIDFunc func(ctx context.Context, tournamentID int32) (db.Tournament, error) + listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) + listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) + getSubmissionsByGameIDAndUserIDFunc func(ctx context.Context, arg db.GetSubmissionsByGameIDAndUserIDParams) ([]db.Submission, error) } func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) { @@ -85,6 +86,13 @@ func (m *mockQuerier) GetLatestStatesOfMainPlayers(ctx context.Context, gameID i return nil, nil } +func (m *mockQuerier) GetSubmissionsByGameIDAndUserID(ctx context.Context, arg db.GetSubmissionsByGameIDAndUserIDParams) ([]db.Submission, error) { + if m.getSubmissionsByGameIDAndUserIDFunc != nil { + return m.getSubmissionsByGameIDAndUserIDFunc(ctx, arg) + } + return nil, nil +} + func (m *mockQuerier) GetTournamentByID(ctx context.Context, tournamentID int32) (db.Tournament, error) { if m.getTournamentByIDFunc != nil { return m.getTournamentByIDFunc(ctx, tournamentID) @@ -137,6 +145,142 @@ func (m *mockAuthenticator) Login(_ context.Context, _, _ string) (int, error) { return m.loginResult, m.loginErr } +func TestGetGamePlaySubmissions_GameNotFound(t *testing.T) { + h := Handler{ + q: &mockQuerier{}, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1} + resp, err := h.GetGamePlaySubmissions(context.Background(), GetGamePlaySubmissionsRequestObject{ + GameID: 999, + }, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := resp.(GetGamePlaySubmissions404JSONResponse); !ok { + t.Errorf("expected 404 response, got %T", resp) + } +} + +func TestGetGamePlaySubmissions_Empty(t *testing.T) { + h := Handler{ + q: &mockQuerier{ + getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{ + GameID: 1, + Language: "php", + }, nil + }, + }, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1} + resp, err := h.GetGamePlaySubmissions(context.Background(), GetGamePlaySubmissionsRequestObject{ + GameID: 1, + }, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + okResp, ok := resp.(GetGamePlaySubmissions200JSONResponse) + if !ok { + t.Fatalf("expected 200 response, got %T", resp) + } + if len(okResp.Submissions) != 0 { + t.Errorf("expected 0 submissions, got %d", len(okResp.Submissions)) + } +} + +func TestGetGamePlaySubmissions_WithSubmissions(t *testing.T) { + now := time.Now() + h := Handler{ + q: &mockQuerier{ + getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{ + GameID: 1, + Language: "php", + }, nil + }, + getSubmissionsByGameIDAndUserIDFunc: func(_ context.Context, arg db.GetSubmissionsByGameIDAndUserIDParams) ([]db.Submission, error) { + if arg.GameID != 1 || arg.UserID != 42 { + t.Errorf("unexpected query params: game_id=%d, user_id=%d", arg.GameID, arg.UserID) + } + return []db.Submission{ + { + SubmissionID: 10, + GameID: 1, + UserID: 42, + Code: "<?php echo 1;", + CodeSize: 14, + Status: "success", + CreatedAt: pgtype.Timestamp{Time: now, Valid: true}, + }, + { + SubmissionID: 9, + GameID: 1, + UserID: 42, + Code: "<?php echo 'hello';", + CodeSize: 20, + Status: "wrong_answer", + CreatedAt: pgtype.Timestamp{Time: now.Add(-5 * time.Minute), Valid: true}, + }, + }, nil + }, + }, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 42} + resp, err := h.GetGamePlaySubmissions(context.Background(), GetGamePlaySubmissionsRequestObject{ + GameID: 1, + }, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + okResp, ok := resp.(GetGamePlaySubmissions200JSONResponse) + if !ok { + t.Fatalf("expected 200 response, got %T", resp) + } + if len(okResp.Submissions) != 2 { + t.Fatalf("expected 2 submissions, got %d", len(okResp.Submissions)) + } + + s0 := okResp.Submissions[0] + if s0.SubmissionID != 10 { + t.Errorf("expected submission_id 10, got %d", s0.SubmissionID) + } + if s0.GameID != 1 { + t.Errorf("expected game_id 1, got %d", s0.GameID) + } + if s0.Code != "<?php echo 1;" { + t.Errorf("expected code '<?php echo 1;', got %q", s0.Code) + } + if s0.CodeSize != 14 { + t.Errorf("expected code_size 14, got %d", s0.CodeSize) + } + if s0.Status != Success { + t.Errorf("expected status 'success', got %q", s0.Status) + } + if s0.CreatedAt != now.Unix() { + t.Errorf("expected created_at %d, got %d", now.Unix(), s0.CreatedAt) + } + + s1 := okResp.Submissions[1] + if s1.SubmissionID != 9 { + t.Errorf("expected submission_id 9, got %d", s1.SubmissionID) + } + if s1.Status != WrongAnswer { + t.Errorf("expected status 'wrong_answer', got %q", s1.Status) + } +} + func TestPostGamePlaySubmit_GameNotFound(t *testing.T) { h := Handler{ q: &mockQuerier{}, diff --git a/backend/api/handler_wrapper.go b/backend/api/handler_wrapper.go index 28b89e4..48a0eef 100644 --- a/backend/api/handler_wrapper.go +++ b/backend/api/handler_wrapper.go @@ -47,6 +47,16 @@ func (h *HandlerWrapper) GetGamePlayLatestState(ctx context.Context, request Get return h.impl.GetGamePlayLatestState(ctx, request, user) } +func (h *HandlerWrapper) GetGamePlaySubmissions(ctx context.Context, request GetGamePlaySubmissionsRequestObject) (GetGamePlaySubmissionsResponseObject, error) { + user, ok := GetUserFromContext(ctx) + if !ok { + return GetGamePlaySubmissions401JSONResponse{ + Message: "Unauthorized", + }, nil + } + return h.impl.GetGamePlaySubmissions(ctx, request, user) +} + func (h *HandlerWrapper) GetGameWatchLatestStates(ctx context.Context, request GetGameWatchLatestStatesRequestObject) (GetGameWatchLatestStatesResponseObject, error) { user, ok := GetUserFromContext(ctx) if !ok { diff --git a/backend/db/querier.go b/backend/db/querier.go index 220a86c..692ea0d 100644 --- a/backend/db/querier.go +++ b/backend/db/querier.go @@ -37,6 +37,7 @@ type Querier interface { GetRanking(ctx context.Context, gameID int32) ([]GetRankingRow, error) GetSubmissionByID(ctx context.Context, submissionID int32) (Submission, error) GetSubmissionsByGameID(ctx context.Context, gameID int32) ([]Submission, error) + GetSubmissionsByGameIDAndUserID(ctx context.Context, arg GetSubmissionsByGameIDAndUserIDParams) ([]Submission, error) GetTestcaseByID(ctx context.Context, testcaseID int32) (Testcase, error) GetTestcaseResultsBySubmissionID(ctx context.Context, submissionID int32) ([]TestcaseResult, error) GetTournamentByID(ctx context.Context, tournamentID int32) (Tournament, error) diff --git a/backend/db/query.sql.go b/backend/db/query.sql.go index 02f1abf..80cbc16 100644 --- a/backend/db/query.sql.go +++ b/backend/db/query.sql.go @@ -705,6 +705,45 @@ func (q *Queries) GetSubmissionsByGameID(ctx context.Context, gameID int32) ([]S return items, nil } +const getSubmissionsByGameIDAndUserID = `-- name: GetSubmissionsByGameIDAndUserID :many +SELECT submission_id, game_id, user_id, code, code_size, status, created_at FROM submissions +WHERE game_id = $1 AND user_id = $2 +ORDER BY created_at DESC +` + +type GetSubmissionsByGameIDAndUserIDParams struct { + GameID int32 + UserID int32 +} + +func (q *Queries) GetSubmissionsByGameIDAndUserID(ctx context.Context, arg GetSubmissionsByGameIDAndUserIDParams) ([]Submission, error) { + rows, err := q.db.Query(ctx, getSubmissionsByGameIDAndUserID, arg.GameID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Submission + for rows.Next() { + var i Submission + if err := rows.Scan( + &i.SubmissionID, + &i.GameID, + &i.UserID, + &i.Code, + &i.CodeSize, + &i.Status, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTestcaseByID = `-- name: GetTestcaseByID :one SELECT testcase_id, problem_id, stdin, stdout FROM testcases WHERE testcase_id = $1 diff --git a/backend/query.sql b/backend/query.sql index 45ac46f..c791c27 100644 --- a/backend/query.sql +++ b/backend/query.sql @@ -259,6 +259,11 @@ WHERE testcase_id = $1; DELETE FROM testcases WHERE testcase_id = $1; +-- name: GetSubmissionsByGameIDAndUserID :many +SELECT * FROM submissions +WHERE game_id = $1 AND user_id = $2 +ORDER BY created_at DESC; + -- name: GetSubmissionsByGameID :many SELECT * FROM submissions diff --git a/frontend/app/App.tsx b/frontend/app/App.tsx index 651de32..9ac8007 100644 --- a/frontend/app/App.tsx +++ b/frontend/app/App.tsx @@ -7,6 +7,7 @@ import GolfPlayPage from "./pages/GolfPlayPage"; import GolfWatchPage from "./pages/GolfWatchPage"; import IndexPage from "./pages/IndexPage"; import LoginPage from "./pages/LoginPage"; +import SubmissionsPage from "./pages/SubmissionsPage"; import TournamentPage from "./pages/TournamentPage"; export default function App() { @@ -35,6 +36,13 @@ export default function App() { </ProtectedRoute> )} </Route> + <Route path="/golf/:gameId/submissions"> + {(params) => ( + <ProtectedRoute> + <SubmissionsPage gameId={params.gameId} /> + </ProtectedRoute> + )} + </Route> <Route path="/golf/:gameId/watch"> {(params) => ( <ProtectedRoute> diff --git a/frontend/app/api/client.ts b/frontend/app/api/client.ts index c9647ba..db5f8c8 100644 --- a/frontend/app/api/client.ts +++ b/frontend/app/api/client.ts @@ -85,6 +85,19 @@ class AuthenticatedApiClient { return data; } + async getGamePlaySubmissions(gameId: number) { + const { data, error } = await client.GET( + "/games/{game_id}/play/submissions", + { + params: { + path: { game_id: gameId }, + }, + }, + ); + if (error) throw new Error(error.message); + return data; + } + async getGameWatchRanking(gameId: number) { const { data, error } = await client.GET("/games/{game_id}/watch/ranking", { params: { diff --git a/frontend/app/api/schema.d.ts b/frontend/app/api/schema.d.ts index b891bfa..e720f52 100644 --- a/frontend/app/api/schema.d.ts +++ b/frontend/app/api/schema.d.ts @@ -68,6 +68,22 @@ export interface paths { patch?: never; trace?: never; }; + "/games/{game_id}/play/submissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getGamePlaySubmissions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/games/{game_id}/play/submit": { parameters: { query?: never; @@ -222,6 +238,14 @@ export interface components { submitted_at: number; code: string | null; }; + Submission: { + submission_id: number; + game_id: number; + code: string; + code_size: number; + status: components["schemas"]["ExecutionStatus"]; + created_at: number; + }; Tournament: { tournament_id: number; display_name: string; @@ -458,6 +482,57 @@ export interface operations { }; }; }; + getGamePlaySubmissions: { + parameters: { + query?: never; + header?: never; + path: { + game_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The request has succeeded. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + submissions: components["schemas"]["Submission"][]; + }; + }; + }; + /** @description Access is unauthorized. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Access is forbidden. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The server cannot find the requested resource. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; postGamePlaySubmit: { parameters: { query?: never; diff --git a/frontend/app/pages/DashboardPage.tsx b/frontend/app/pages/DashboardPage.tsx index 708a867..3191f1b 100644 --- a/frontend/app/pages/DashboardPage.tsx +++ b/frontend/app/pages/DashboardPage.tsx @@ -74,6 +74,9 @@ export default function DashboardPage() { <NavigateLink to={`/golf/${game.game_id}/watch`}> 観戦 </NavigateLink> + <NavigateLink to={`/golf/${game.game_id}/submissions`}> + 提出履歴 + </NavigateLink> </div> </li> ))} diff --git a/frontend/app/pages/SubmissionsPage.test.tsx b/frontend/app/pages/SubmissionsPage.test.tsx new file mode 100644 index 0000000..f01f4c9 --- /dev/null +++ b/frontend/app/pages/SubmissionsPage.test.tsx @@ -0,0 +1,17 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import SubmissionsPage from "./SubmissionsPage"; + +afterEach(() => { + cleanup(); +}); + +describe("SubmissionsPage", () => { + test("shows loading state initially", () => { + render(<SubmissionsPage gameId="1" />); + expect(screen.getByText("Loading...")).toBeDefined(); + }); +}); diff --git a/frontend/app/pages/SubmissionsPage.tsx b/frontend/app/pages/SubmissionsPage.tsx new file mode 100644 index 0000000..2c3329d --- /dev/null +++ b/frontend/app/pages/SubmissionsPage.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import { createApiClient } from "../api/client"; +import type { components } from "../api/schema"; +import BorderedContainerWithCaption from "../components/BorderedContainerWithCaption"; +import NavigateLink from "../components/NavigateLink"; +import SubmitStatusLabel from "../components/SubmitStatusLabel"; +import { APP_NAME } from "../config"; +import { usePageTitle } from "../hooks/usePageTitle"; + +type Submission = components["schemas"]["Submission"]; + +export default function SubmissionsPage({ gameId }: { gameId: string }) { + usePageTitle(`Submissions | ${APP_NAME}`); + + const [submissions, setSubmissions] = useState<Submission[]>([]); + const [loading, setLoading] = useState(true); + const [expandedId, setExpandedId] = useState<number | null>(null); + + const numericGameId = Number(gameId); + + useEffect(() => { + const apiClient = createApiClient(); + apiClient + .getGamePlaySubmissions(numericGameId) + .then(({ submissions }) => setSubmissions(submissions)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [numericGameId]); + + if (loading) { + return ( + <div className="min-h-screen bg-gray-100 flex items-center justify-center"> + <p className="text-gray-500">Loading...</p> + </div> + ); + } + + return ( + <div className="p-6 bg-gray-100 min-h-screen flex flex-col items-center gap-4"> + <BorderedContainerWithCaption caption="提出履歴"> + <div className="px-4"> + {submissions.length === 0 ? ( + <p>提出履歴はありません</p> + ) : ( + <ul className="divide-y divide-gray-300"> + {submissions.map((s) => ( + <li key={s.submission_id} className="py-3"> + <div className="flex justify-between items-center gap-4"> + <div className="flex items-center gap-3"> + <StatusBadge status={s.status} /> + <span className="font-mono text-lg font-bold"> + {s.code_size} + <span className="text-sm font-normal text-gray-500 ml-1"> + bytes + </span> + </span> + </div> + <div className="flex items-center gap-3"> + <span className="text-sm text-gray-500"> + {formatDate(s.created_at)} + </span> + <button + type="button" + onClick={() => + setExpandedId( + expandedId === s.submission_id + ? null + : s.submission_id, + ) + } + className="text-sm text-sky-600 hover:text-sky-800 underline" + > + {expandedId === s.submission_id + ? "コードを隠す" + : "コードを見る"} + </button> + </div> + </div> + {expandedId === s.submission_id && ( + <pre className="mt-2 p-3 bg-gray-800 text-gray-100 rounded text-sm overflow-x-auto"> + {s.code} + </pre> + )} + </li> + ))} + </ul> + )} + </div> + </BorderedContainerWithCaption> + <NavigateLink to={`/golf/${gameId}/play`}>対戦に戻る</NavigateLink> + <NavigateLink to="/dashboard">ダッシュボードに戻る</NavigateLink> + </div> + ); +} + +function StatusBadge({ + status, +}: { + status: components["schemas"]["ExecutionStatus"]; +}) { + const colorClass = + status === "success" + ? "bg-green-100 text-green-800" + : status === "running" + ? "bg-yellow-100 text-yellow-800" + : status === "none" + ? "bg-gray-100 text-gray-800" + : "bg-red-100 text-red-800"; + + return ( + <span className={`px-2 py-1 rounded text-sm font-medium ${colorClass}`}> + <SubmitStatusLabel status={status} /> + </span> + ); +} + +function formatDate(unixTimestamp: number): string { + const date = new Date(unixTimestamp * 1000); + return date.toLocaleString("ja-JP", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} diff --git a/openapi/api-server.yaml b/openapi/api-server.yaml index ed535d3..9749f25 100644 --- a/openapi/api-server.yaml +++ b/openapi/api-server.yaml @@ -153,6 +153,47 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /games/{game_id}/play/submissions: + get: + operationId: getGamePlaySubmissions + parameters: + - name: game_id + in: path + required: true + schema: + type: integer + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + properties: + submissions: + type: array + items: + $ref: '#/components/schemas/Submission' + required: + - submissions + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /games/{game_id}/play/submit: post: operationId: postGamePlaySubmit @@ -502,6 +543,29 @@ components: code: type: string nullable: true + Submission: + type: object + required: + - submission_id + - game_id + - code + - code_size + - status + - created_at + properties: + submission_id: + type: integer + game_id: + type: integer + code: + type: string + code_size: + type: integer + status: + $ref: '#/components/schemas/ExecutionStatus' + created_at: + type: integer + x-go-type: int64 Tournament: type: object required: diff --git a/typespec/api-server/models.tsp b/typespec/api-server/models.tsp index 6605767..570977c 100644 --- a/typespec/api-server/models.tsp +++ b/typespec/api-server/models.tsp @@ -119,6 +119,17 @@ model TournamentEntry { seed: integer; } +model Submission { + submission_id: integer; + game_id: integer; + code: string; + code_size: integer; + status: ExecutionStatus; + + @extension("x-go-type", "int64") + created_at: integer; +} + model TournamentMatch { tournament_match_id: integer; round: integer; diff --git a/typespec/api-server/routes.tsp b/typespec/api-server/routes.tsp index a67ab8f..4f90822 100644 --- a/typespec/api-server/routes.tsp +++ b/typespec/api-server/routes.tsp @@ -88,6 +88,15 @@ op postGamePlaySubmit( @statusCode statusCode: 200; } | UnauthorizedError | ForbiddenError | NotFoundError; +@route("/games/{game_id}/play/submissions") +@get +@operationId("getGamePlaySubmissions") +op getGamePlaySubmissions(@path game_id: integer): { + @body body: { + submissions: Submission[]; + }; +} | UnauthorizedError | ForbiddenError | NotFoundError; + // ---------- Watch ---------- @route("/games/{game_id}/watch/ranking") |
