aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--backend/api/generated.go151
-rw-r--r--backend/api/handler.go38
-rw-r--r--backend/api/handler_test.go166
-rw-r--r--backend/api/handler_wrapper.go10
-rw-r--r--backend/db/querier.go1
-rw-r--r--backend/db/query.sql.go39
-rw-r--r--backend/query.sql5
-rw-r--r--frontend/app/App.tsx8
-rw-r--r--frontend/app/api/client.ts13
-rw-r--r--frontend/app/api/schema.d.ts75
-rw-r--r--frontend/app/pages/DashboardPage.tsx3
-rw-r--r--frontend/app/pages/SubmissionsPage.test.tsx17
-rw-r--r--frontend/app/pages/SubmissionsPage.tsx126
-rw-r--r--openapi/api-server.yaml64
-rw-r--r--typespec/api-server/models.tsp11
-rw-r--r--typespec/api-server/routes.tsp9
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")