@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/api/openapi.yaml +119 -0
- package/backend/cmd/server/main.go +36 -2
- package/backend/db/migration/V005__idea.sql +49 -0
- package/backend/db/migration/V006__login_lock.sql +15 -0
- package/backend/db/migration/V007__login_attempt.sql +15 -0
- package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
- package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
- package/backend/go.mod +17 -3
- package/backend/go.sum +48 -7
- package/backend/internal/account/account_test.go +6 -6
- package/backend/internal/account/integration_test.go +45 -9
- package/backend/internal/account/internal/passwords/NOTICE +36 -0
- package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
- package/backend/internal/account/internal/passwords/passwords.go +37 -0
- package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
- package/backend/internal/account/keyboard.go +75 -0
- package/backend/internal/account/keyboard_test.go +43 -0
- package/backend/internal/account/logging_integration_test.go +1 -1
- package/backend/internal/account/password.go +131 -0
- package/backend/internal/account/sequence.go +54 -0
- package/backend/internal/account/sequence_test.go +38 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +262 -0
- package/backend/internal/auth/internal/store/login_attempt.go +53 -0
- package/backend/internal/auth/internal/store/login_lock.go +77 -0
- package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
- package/backend/internal/auth/internal/store/session.go +15 -7
- package/backend/internal/auth/login.go +16 -4
- package/backend/internal/auth/login_integration_test.go +14 -1
- package/backend/internal/auth/login_ip_integration_test.go +153 -0
- package/backend/internal/auth/login_lock_integration_test.go +205 -0
- package/backend/internal/auth/login_test.go +15 -0
- package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
- package/backend/internal/auth/purge.go +93 -0
- package/backend/internal/auth/purge_integration_test.go +220 -0
- package/backend/internal/auth/purge_test.go +51 -0
- package/backend/internal/auth/session.go +0 -6
- package/backend/internal/auth/session_integration_test.go +3 -3
- package/backend/internal/auth/throttle.go +122 -0
- package/backend/internal/deadline/gate.go +80 -0
- package/backend/internal/deadline/gate_integration_test.go +67 -0
- package/backend/internal/deadline/gate_test.go +61 -0
- package/backend/internal/idea/create.go +356 -0
- package/backend/internal/idea/create_integration_test.go +680 -0
- package/backend/internal/idea/download.go +193 -0
- package/backend/internal/idea/download_integration_test.go +311 -0
- package/backend/internal/idea/download_test.go +25 -0
- package/backend/internal/idea/handler.go +151 -0
- package/backend/internal/idea/handler_integration_test.go +107 -0
- package/backend/internal/idea/integration_test.go +149 -0
- package/backend/internal/idea/internal/store/list.go +146 -0
- package/backend/internal/idea/internal/store/list_test.go +33 -0
- package/backend/internal/idea/internal/store/store.go +166 -0
- package/backend/internal/idea/list.go +166 -0
- package/backend/internal/idea/list_integration_test.go +252 -0
- package/backend/internal/idea/list_test.go +96 -0
- package/backend/internal/idea/service.go +54 -0
- package/backend/internal/server/api.go +22 -0
- package/backend/internal/server/binding.go +4 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/server.go +20 -1
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/server/slides_test.go +82 -0
- package/backend/internal/shared/config/config.go +44 -1
- package/backend/internal/shared/config/config_test.go +21 -13
- package/backend/internal/shared/db/intx_integration_test.go +179 -0
- package/backend/internal/shared/dbtest/dbtest.go +21 -3
- package/backend/internal/shared/dbtest/faults.go +162 -0
- package/backend/internal/shared/job/job.go +54 -0
- package/backend/internal/shared/job/job_test.go +105 -0
- package/backend/internal/shared/web/multipart.go +175 -0
- package/backend/internal/shared/web/multipart_test.go +169 -0
- package/backend/internal/shared/web/problem.go +1 -0
- package/backend/internal/shared/web/request.go +42 -1
- package/backend/internal/shared/web/transport.go +4 -3
- package/backend/internal/shared/web/web_test.go +36 -0
- package/backend/internal/storage/expectedfilename.go +43 -0
- package/backend/internal/storage/expectedfilename_test.go +16 -0
- package/backend/internal/storage/internal/store/pending.go +41 -0
- package/backend/internal/storage/minio.go +139 -0
- package/backend/internal/storage/minio_integration_test.go +87 -0
- package/backend/internal/storage/minio_test.go +84 -0
- package/backend/internal/storage/pending.go +144 -0
- package/backend/internal/storage/pending_integration_test.go +324 -0
- package/backend/internal/storage/pending_test.go +23 -0
- package/backend/internal/storage/validator.go +100 -0
- package/backend/internal/storage/validator_test.go +95 -0
- package/backend/test/arch/arch_test.go +5 -1
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/load_test.go +12 -0
- package/backend/test/matrix/matrix_integration_test.go +92 -5
- package/backend/test/migration/pending_file_deletion_test.go +46 -0
- package/docs/API/api-endpoints.md +1 -1
- package/docs/API/error-codes.md +1 -1
- package/docs/Architecture/Architecture.md +1 -1
- package/docs/Architecture/Backend.md +2 -1
- package/frontend/Dockerfile +2 -0
- package/frontend/package-lock.json +1 -0
- package/frontend/package.json +1 -0
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
- package/frontend/src/app/pages/ideas.tsx +23 -9
- package/frontend/src/app/pages/login.test.tsx +38 -1
- package/frontend/src/app/pages/register.test.tsx +32 -0
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/features/auth/LoginForm.tsx +21 -3
- package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
- package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
- package/frontend/src/features/auth/RegisterForm.tsx +11 -2
- package/frontend/src/features/auth/common-passwords.ts +31 -0
- package/frontend/src/features/auth/password-policy.test.ts +64 -0
- package/frontend/src/features/auth/password-policy.ts +168 -0
- package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
- package/frontend/src/features/auth/useCountdown.ts +28 -0
- package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
- package/frontend/src/features/idea/create.ts +123 -0
- package/frontend/src/shared/api/activity.ts +20 -0
- package/frontend/src/shared/api/http.ts +2 -0
- package/frontend/src/shared/api/index.ts +1 -0
- package/frontend/src/shared/api/types.generated.ts +319 -1
- package/frontend/src/shared/i18n/locales/en.ts +46 -4
- package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
- package/frontend/src/shared/slides/index.ts +74 -0
- package/frontend/src/shared/slides/slides.test.ts +51 -0
- package/frontend/src/shared/ui/copy-button.tsx +52 -0
- package/frontend/src/shared/ui/native-select.tsx +27 -0
- package/frontend/src/shared/ui/textarea.tsx +19 -0
- package/frontend/src/test-setup.ts +37 -2
- package/frontend/vite.config.ts +3 -1
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +582 -0
- package/testdata/permission-matrix.json +71 -0
- package/testdata/schema/expected-filename.schema.json +32 -0
- package/testdata/schema/password.schema.json +59 -0
- package/testdata/schema/permission-matrix.schema.json +22 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package idea_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"testing"
|
|
11
|
+
|
|
12
|
+
"github.com/go-chi/chi/v5"
|
|
13
|
+
"github.com/google/uuid"
|
|
14
|
+
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
|
|
18
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/server"
|
|
19
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
20
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
func TestGetIdeaHandler(t *testing.T) {
|
|
24
|
+
d := dbtest.New(t)
|
|
25
|
+
leader := addUser(t, d, "100001", "USER")
|
|
26
|
+
other := addUser(t, d, "100002", "USER")
|
|
27
|
+
admin := addUser(t, d, "900001", "ADMIN")
|
|
28
|
+
n := newIdea(leader.UserID, "Idea A")
|
|
29
|
+
if err := insert(d, n); err != nil {
|
|
30
|
+
t.Fatal(err)
|
|
31
|
+
}
|
|
32
|
+
aud := audit.New(d, nil)
|
|
33
|
+
errs := server.ErrorAdapter{Audit: aud}
|
|
34
|
+
h := idea.Handler{Service: &idea.Service{DB: d, Audit: aud}, ExpectedFilename: expectedBase, Fail: errs.Write}
|
|
35
|
+
|
|
36
|
+
call := func(p security.Principal, id uuid.UUID) *httptest.ResponseRecorder {
|
|
37
|
+
r := chi.NewRouter()
|
|
38
|
+
r.Get("/ideas/{id}", func(w http.ResponseWriter, req *http.Request) { h.GetIdea(w, req, api.IdeaId(id)) })
|
|
39
|
+
req := httptest.NewRequest(http.MethodGet, "/ideas/"+id.String(), nil)
|
|
40
|
+
req = req.WithContext(security.WithPrincipal(context.Background(), p))
|
|
41
|
+
rec := httptest.NewRecorder()
|
|
42
|
+
r.ServeHTTP(rec, req)
|
|
43
|
+
return rec
|
|
44
|
+
}
|
|
45
|
+
count := func(where string) (c int) {
|
|
46
|
+
if err := d.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE ` + where).Scan(&c); err != nil {
|
|
47
|
+
t.Fatal(err)
|
|
48
|
+
}
|
|
49
|
+
return c
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Leader: 200, pendingRequests is [] (not null), expected filename.
|
|
53
|
+
rec := call(leader, n.ID)
|
|
54
|
+
if rec.Code != 200 {
|
|
55
|
+
t.Fatalf("leader: %d %s", rec.Code, rec.Body)
|
|
56
|
+
}
|
|
57
|
+
var body map[string]any
|
|
58
|
+
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
59
|
+
if body["expectedFilenameBase"] != "I_BSID_User 100001_100001_Idea A" ||
|
|
60
|
+
body["slides"].(map[string]any)["downloadFilename"] != "I_BSID_User 100001_100001_Idea A.pdf" {
|
|
61
|
+
t.Errorf("filename: %v", body)
|
|
62
|
+
}
|
|
63
|
+
if pr, ok := body["pendingRequests"].([]any); !ok || len(pr) != 0 {
|
|
64
|
+
t.Errorf("pendingRequests = %v", body["pendingRequests"])
|
|
65
|
+
}
|
|
66
|
+
if count(`action = 'IDEA_VIEW'`) != 0 {
|
|
67
|
+
t.Error("a Leader view must not write IDEA_VIEW")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Admin: 200 and one IDEA_VIEW SUCCESS row.
|
|
71
|
+
if rec := call(admin, n.ID); rec.Code != 200 {
|
|
72
|
+
t.Fatalf("admin: %d", rec.Code)
|
|
73
|
+
}
|
|
74
|
+
if count(`action = 'IDEA_VIEW' AND outcome = 'SUCCESS' AND actor_role = 'ADMIN'`) != 1 {
|
|
75
|
+
t.Error("admin view: want one IDEA_VIEW SUCCESS row")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Hidden and missing: identical 404 (bar the request ID), both audited DENIED.
|
|
79
|
+
hidden, missing := call(other, n.ID), call(other, uuid.New())
|
|
80
|
+
if hidden.Code != 404 || missing.Code != 404 {
|
|
81
|
+
t.Fatalf("codes %d %d", hidden.Code, missing.Code)
|
|
82
|
+
}
|
|
83
|
+
strip := func(rr *httptest.ResponseRecorder) (map[string]any, http.Header) {
|
|
84
|
+
var m map[string]any
|
|
85
|
+
_ = json.Unmarshal(rr.Body.Bytes(), &m)
|
|
86
|
+
delete(m, "requestId")
|
|
87
|
+
return m, rr.Header()
|
|
88
|
+
}
|
|
89
|
+
hb, hh := strip(hidden)
|
|
90
|
+
mb, mh := strip(missing)
|
|
91
|
+
if len(hb) != len(mb) || hb["code"] != mb["code"] || hb["detail"] != mb["detail"] {
|
|
92
|
+
t.Errorf("bodies differ: %v vs %v", hb, mb)
|
|
93
|
+
}
|
|
94
|
+
for k := range hh {
|
|
95
|
+
if hh.Get(k) != mh.Get(k) {
|
|
96
|
+
t.Errorf("header %s differs: %q vs %q", k, hh.Get(k), mh.Get(k))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if got := count(`action = 'IDEA_VIEW' AND outcome = 'DENIED' AND status = 404 AND error_code = 'NOT_FOUND'`); got != 2 {
|
|
100
|
+
t.Errorf("DENIED rows = %d, want 2", got)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// expectedBase mirrors storage.ExpectedFilenameBase (idea must not import storage).
|
|
105
|
+
func expectedBase(category, org, leaderName, leaderEmpNo, ideaName string) (string, error) {
|
|
106
|
+
return "I_" + org + "_" + leaderName + "_" + leaderEmpNo + "_" + ideaName, nil
|
|
107
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package idea_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"errors"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
|
|
12
|
+
"github.com/google/uuid"
|
|
13
|
+
|
|
14
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea/internal/store"
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
18
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
19
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const hash = "$2a$10$" + "abcdefghijklmnopqrstuuabcdefghijklmnopqrstuuabcdefghi" // 7 + 53 chars
|
|
23
|
+
|
|
24
|
+
func addUser(t *testing.T, d *sql.DB, empNo, role string) security.Principal {
|
|
25
|
+
t.Helper()
|
|
26
|
+
id := uuid.New()
|
|
27
|
+
var email any
|
|
28
|
+
if role == "USER" {
|
|
29
|
+
email = empNo + "@example.com"
|
|
30
|
+
}
|
|
31
|
+
if _, err := d.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role) VALUES (?,?,?,?,?,?)`,
|
|
32
|
+
id.String(), empNo, "User "+empNo, email, hash, role); err != nil {
|
|
33
|
+
t.Fatal(err)
|
|
34
|
+
}
|
|
35
|
+
return security.Principal{UserID: id, EmpNo: empNo, Role: security.Role(role)}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func newIdea(leader uuid.UUID, name string) store.NewIdea {
|
|
39
|
+
id := uuid.New()
|
|
40
|
+
return store.NewIdea{
|
|
41
|
+
ID: id, LeaderID: leader, Category: "COOL_IDEA", Org: "BSID", Direction: "TALENT_DEVELOPMENT",
|
|
42
|
+
Name: name, Summary: "s",
|
|
43
|
+
Slides: store.Slides{ObjectKey: "slides/" + id.String() + "/" + uuid.NewString() + ".pdf",
|
|
44
|
+
Ext: "pdf", Size: 1024, SHA256: strings.Repeat("ab", 32)},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func insert(d *sql.DB, n store.NewIdea) error {
|
|
49
|
+
tx, err := d.Begin()
|
|
50
|
+
if err != nil {
|
|
51
|
+
return err
|
|
52
|
+
}
|
|
53
|
+
if err := store.InsertIdea(context.Background(), tx, n); err != nil {
|
|
54
|
+
_ = tx.Rollback()
|
|
55
|
+
return err
|
|
56
|
+
}
|
|
57
|
+
return tx.Commit()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func TestVisibilityPerRole(t *testing.T) {
|
|
61
|
+
d := dbtest.New(t)
|
|
62
|
+
leader := addUser(t, d, "100001", "USER")
|
|
63
|
+
other := addUser(t, d, "100002", "USER")
|
|
64
|
+
admin := addUser(t, d, "900001", "ADMIN")
|
|
65
|
+
n := newIdea(leader.UserID, "Idea A")
|
|
66
|
+
if err := insert(d, n); err != nil {
|
|
67
|
+
t.Fatal(err)
|
|
68
|
+
}
|
|
69
|
+
svc := &idea.Service{DB: d}
|
|
70
|
+
ctx := context.Background()
|
|
71
|
+
|
|
72
|
+
for _, tc := range []struct {
|
|
73
|
+
who string
|
|
74
|
+
p security.Principal
|
|
75
|
+
visible bool
|
|
76
|
+
}{{"leader", leader, true}, {"admin", admin, true}, {"other user", other, false}} {
|
|
77
|
+
got, err := svc.Get(ctx, n.ID, tc.p)
|
|
78
|
+
if tc.visible {
|
|
79
|
+
if err != nil || got.ID != n.ID || got.LeaderEmpNo != "100001" || got.Version != 0 || got.Slides.Ext != "pdf" {
|
|
80
|
+
t.Errorf("%s: got %+v, %v", tc.who, got, err)
|
|
81
|
+
}
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if !isNotFound(err) {
|
|
85
|
+
t.Errorf("%s: want NOT_FOUND, got %v", tc.who, err)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A missing Idea is indistinguishable from a hidden one.
|
|
90
|
+
_, errMissing := svc.Get(ctx, uuid.New(), leader)
|
|
91
|
+
_, errHidden := svc.Get(ctx, n.ID, other)
|
|
92
|
+
if !isNotFound(errMissing) || errMissing.Error() != errHidden.Error() {
|
|
93
|
+
t.Errorf("missing %v vs hidden %v", errMissing, errHidden)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func isNotFound(err error) bool {
|
|
98
|
+
var we *web.Error
|
|
99
|
+
return errors.As(err, &we) && we.Error() == "NOT_FOUND"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func TestUniqueLeaderName(t *testing.T) {
|
|
103
|
+
d := dbtest.New(t)
|
|
104
|
+
a := addUser(t, d, "100001", "USER")
|
|
105
|
+
b := addUser(t, d, "100002", "USER")
|
|
106
|
+
if err := insert(d, newIdea(a.UserID, "Same")); err != nil {
|
|
107
|
+
t.Fatal(err)
|
|
108
|
+
}
|
|
109
|
+
err := insert(d, newIdea(a.UserID, "Same"))
|
|
110
|
+
if key, ok := db.DuplicateKey(err); !ok || key != "uq_idea_leader_name" {
|
|
111
|
+
t.Errorf("same leader, same name: key %q ok %v err %v", key, ok, err)
|
|
112
|
+
}
|
|
113
|
+
if err := insert(d, newIdea(a.UserID, "same")); err != nil {
|
|
114
|
+
t.Errorf("name is case-sensitive: %v", err)
|
|
115
|
+
}
|
|
116
|
+
if err := insert(d, newIdea(b.UserID, "Same")); err != nil {
|
|
117
|
+
t.Errorf("names are unique per leader, not globally: %v", err)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func TestChecksRejectBadRows(t *testing.T) {
|
|
122
|
+
d := dbtest.New(t)
|
|
123
|
+
a := addUser(t, d, "100001", "USER")
|
|
124
|
+
for name, mut := range map[string]func(*store.NewIdea){
|
|
125
|
+
"category": func(n *store.NewIdea) { n.Category = "X" },
|
|
126
|
+
"org": func(n *store.NewIdea) { n.Org = "ZZZZ" },
|
|
127
|
+
"direction": func(n *store.NewIdea) { n.Direction = "X" },
|
|
128
|
+
"empty name": func(n *store.NewIdea) { n.Name = "" },
|
|
129
|
+
"slash name": func(n *store.NewIdea) { n.Name = "a/b" },
|
|
130
|
+
"long name": func(n *store.NewIdea) { n.Name = strings.Repeat("好", 76) },
|
|
131
|
+
"empty summary": func(n *store.NewIdea) { n.Summary = "" },
|
|
132
|
+
"ext": func(n *store.NewIdea) { n.Slides.Ext = "docx" },
|
|
133
|
+
"size zero": func(n *store.NewIdea) { n.Slides.Size = 0 },
|
|
134
|
+
"size over 5MiB": func(n *store.NewIdea) { n.Slides.Size = 5*1024*1024 + 1 },
|
|
135
|
+
"sha upper": func(n *store.NewIdea) { n.Slides.SHA256 = strings.Repeat("AB", 32) },
|
|
136
|
+
"no leader": func(n *store.NewIdea) { n.LeaderID = uuid.New() },
|
|
137
|
+
} {
|
|
138
|
+
n := newIdea(a.UserID, "ok")
|
|
139
|
+
mut(&n)
|
|
140
|
+
if err := insert(d, n); err == nil {
|
|
141
|
+
t.Errorf("%s: row accepted", name)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
n := newIdea(a.UserID, strings.Repeat("好", 75))
|
|
145
|
+
n.Slides.Size = 5 * 1024 * 1024
|
|
146
|
+
if err := insert(d, n); err != nil {
|
|
147
|
+
t.Errorf("boundary values rejected: %v", err)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
package store
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"fmt"
|
|
7
|
+
"strings"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// ListFilter is the validated filter set of GET /ideas (PRD 6.6). A nil or
|
|
14
|
+
// empty field means "no filter". CreatedFrom is inclusive, CreatedTo exclusive.
|
|
15
|
+
type ListFilter struct {
|
|
16
|
+
Org, Category, Direction string
|
|
17
|
+
LeaderEmpNo string
|
|
18
|
+
CreatedFrom, CreatedTo *time.Time
|
|
19
|
+
// Q is a case-insensitive "name contains" term; %, _ and the escape
|
|
20
|
+
// character are literal.
|
|
21
|
+
Q string
|
|
22
|
+
// SortName sorts by name (code point order) instead of created_at; Desc is
|
|
23
|
+
// the direction. The handler resolves the per-field default direction.
|
|
24
|
+
SortName, Desc bool
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// likeEscape is the ESCAPE character of the name search. It is not a backslash,
|
|
28
|
+
// so the SQL literal does not depend on NO_BACKSLASH_ESCAPES.
|
|
29
|
+
const likeEscape = "!"
|
|
30
|
+
|
|
31
|
+
// containsPattern turns a user term into a LIKE pattern matching names that
|
|
32
|
+
// contain it, with every wildcard escaped.
|
|
33
|
+
func containsPattern(q string) string {
|
|
34
|
+
r := strings.NewReplacer(likeEscape, likeEscape+likeEscape, "%", likeEscape+"%", "_", likeEscape+"_")
|
|
35
|
+
return "%" + r.Replace(q) + "%"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func (f ListFilter) orderBy() string {
|
|
39
|
+
dir := "ASC"
|
|
40
|
+
if f.Desc {
|
|
41
|
+
dir = "DESC"
|
|
42
|
+
}
|
|
43
|
+
col := "i.created_at"
|
|
44
|
+
if f.SortName {
|
|
45
|
+
col = "i.name"
|
|
46
|
+
}
|
|
47
|
+
return col + " " + dir + ", i.id " + dir
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Summary is one row of the list (IdeaSummary: no summary text).
|
|
51
|
+
type Summary struct {
|
|
52
|
+
ID uuid.UUID
|
|
53
|
+
Name string
|
|
54
|
+
Category string
|
|
55
|
+
Org string
|
|
56
|
+
Direction string
|
|
57
|
+
LeaderID uuid.UUID
|
|
58
|
+
LeaderEmpNo string
|
|
59
|
+
LeaderName string
|
|
60
|
+
MemberCount int
|
|
61
|
+
CreatedAt time.Time
|
|
62
|
+
SlidesUpdatedAt time.Time
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// dbTime is the DATETIME(6) literal of an instant; the database stores UTC.
|
|
66
|
+
func dbTime(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05.000000") }
|
|
67
|
+
|
|
68
|
+
// ListVisible returns one page of the Ideas the viewer may see, in the requested order
|
|
69
|
+
// (id is the final tie-breaker), and the total number of matches. It is built
|
|
70
|
+
// on VisibleWhere like every other read. A page beyond the last is empty.
|
|
71
|
+
func ListVisible(ctx context.Context, q interface {
|
|
72
|
+
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
|
73
|
+
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
|
74
|
+
}, viewer uuid.UUID, isAdmin bool, f ListFilter, limit, offset int) ([]Summary, int, error) {
|
|
75
|
+
admin := 0
|
|
76
|
+
if isAdmin {
|
|
77
|
+
admin = 1
|
|
78
|
+
}
|
|
79
|
+
where := []string{VisibleWhere}
|
|
80
|
+
args := []any{admin, viewer.String()}
|
|
81
|
+
add := func(cond string, v any) {
|
|
82
|
+
where = append(where, cond)
|
|
83
|
+
args = append(args, v)
|
|
84
|
+
}
|
|
85
|
+
if f.Org != "" {
|
|
86
|
+
add("i.org = ?", f.Org)
|
|
87
|
+
}
|
|
88
|
+
if f.Category != "" {
|
|
89
|
+
add("i.category = ?", f.Category)
|
|
90
|
+
}
|
|
91
|
+
if f.Direction != "" {
|
|
92
|
+
add("i.direction = ?", f.Direction)
|
|
93
|
+
}
|
|
94
|
+
if f.LeaderEmpNo != "" {
|
|
95
|
+
add("u.emp_no = ?", f.LeaderEmpNo)
|
|
96
|
+
}
|
|
97
|
+
if f.Q != "" {
|
|
98
|
+
add("LOWER(i.name) LIKE LOWER(?) ESCAPE '"+likeEscape+"'", containsPattern(f.Q))
|
|
99
|
+
}
|
|
100
|
+
if f.CreatedFrom != nil {
|
|
101
|
+
add("i.created_at >= ?", dbTime(*f.CreatedFrom))
|
|
102
|
+
}
|
|
103
|
+
if f.CreatedTo != nil {
|
|
104
|
+
add("i.created_at < ?", dbTime(*f.CreatedTo))
|
|
105
|
+
}
|
|
106
|
+
cond := strings.Join(where, " AND ")
|
|
107
|
+
|
|
108
|
+
var total int
|
|
109
|
+
if err := q.QueryRowContext(ctx,
|
|
110
|
+
`SELECT COUNT(*) FROM idea i JOIN app_user u ON u.id = i.leader_id WHERE `+cond, args...).Scan(&total); err != nil {
|
|
111
|
+
return nil, 0, fmt.Errorf("count ideas: %w", err)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// TODO(B5-01): memberCount counts ACCEPTED idea_member rows (Leader excluded).
|
|
115
|
+
rows, err := q.QueryContext(ctx,
|
|
116
|
+
`SELECT i.id, i.name, i.category, i.org, i.direction, i.leader_id, u.emp_no, u.name,
|
|
117
|
+
0, i.created_at, i.slides_updated_at
|
|
118
|
+
FROM idea i JOIN app_user u ON u.id = i.leader_id
|
|
119
|
+
WHERE `+cond+`
|
|
120
|
+
ORDER BY `+f.orderBy()+`
|
|
121
|
+
LIMIT ? OFFSET ?`, append(args, limit, offset)...)
|
|
122
|
+
if err != nil {
|
|
123
|
+
return nil, 0, fmt.Errorf("list ideas: %w", err)
|
|
124
|
+
}
|
|
125
|
+
defer func() { _ = rows.Close() }()
|
|
126
|
+
out := []Summary{}
|
|
127
|
+
for rows.Next() {
|
|
128
|
+
var s Summary
|
|
129
|
+
var id, lid string
|
|
130
|
+
if err := rows.Scan(&id, &s.Name, &s.Category, &s.Org, &s.Direction, &lid, &s.LeaderEmpNo,
|
|
131
|
+
&s.LeaderName, &s.MemberCount, &s.CreatedAt, &s.SlidesUpdatedAt); err != nil {
|
|
132
|
+
return nil, 0, fmt.Errorf("scan idea: %w", err)
|
|
133
|
+
}
|
|
134
|
+
if s.ID, err = uuid.Parse(id); err != nil {
|
|
135
|
+
return nil, 0, fmt.Errorf("idea id: %w", err)
|
|
136
|
+
}
|
|
137
|
+
if s.LeaderID, err = uuid.Parse(lid); err != nil {
|
|
138
|
+
return nil, 0, fmt.Errorf("idea leader id: %w", err)
|
|
139
|
+
}
|
|
140
|
+
out = append(out, s)
|
|
141
|
+
}
|
|
142
|
+
if err := rows.Err(); err != nil {
|
|
143
|
+
return nil, 0, fmt.Errorf("list ideas: %w", err)
|
|
144
|
+
}
|
|
145
|
+
return out, total, nil
|
|
146
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package store
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestContainsPattern(t *testing.T) {
|
|
6
|
+
for in, want := range map[string]string{
|
|
7
|
+
"abc": "%abc%",
|
|
8
|
+
"50%": "%50!%%",
|
|
9
|
+
"a_b": "%a!_b%",
|
|
10
|
+
"hi!": "%hi!!%",
|
|
11
|
+
"%_!": "%!%!_!!%",
|
|
12
|
+
`a\b`: `%a\b%`,
|
|
13
|
+
"綠能": "%綠能%",
|
|
14
|
+
"": "%%",
|
|
15
|
+
} {
|
|
16
|
+
if got := containsPattern(in); got != want {
|
|
17
|
+
t.Errorf("containsPattern(%q) = %q, want %q", in, got, want)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func TestOrderBy(t *testing.T) {
|
|
23
|
+
for f, want := range map[ListFilter]string{
|
|
24
|
+
{}: "i.created_at ASC, i.id ASC",
|
|
25
|
+
{Desc: true}: "i.created_at DESC, i.id DESC",
|
|
26
|
+
{SortName: true}: "i.name ASC, i.id ASC",
|
|
27
|
+
{SortName: true, Desc: true}: "i.name DESC, i.id DESC",
|
|
28
|
+
} {
|
|
29
|
+
if got := f.orderBy(); got != want {
|
|
30
|
+
t.Errorf("%+v: %q, want %q", f, got, want)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Package store holds the SQL of the idea module. Only the idea module may
|
|
2
|
+
// import it (Go's internal rule); no other module touches the idea table.
|
|
3
|
+
package store
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"errors"
|
|
9
|
+
"fmt"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/google/uuid"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// ErrNotFound is returned when no row exists or the viewer may not see it. The
|
|
16
|
+
// two cases are deliberately indistinguishable (PRD 6.6, Backend.md 8).
|
|
17
|
+
var ErrNotFound = errors.New("idea not found")
|
|
18
|
+
|
|
19
|
+
// Querier is satisfied by *sql.DB, *sql.Tx and *sql.Conn.
|
|
20
|
+
type Querier interface {
|
|
21
|
+
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// NewIdea is a row to insert. Every value is already validated and cleaned;
|
|
25
|
+
// the table's CHECKs are the second line of defence (V005).
|
|
26
|
+
type NewIdea struct {
|
|
27
|
+
ID uuid.UUID
|
|
28
|
+
LeaderID uuid.UUID
|
|
29
|
+
Category string
|
|
30
|
+
Org string
|
|
31
|
+
Direction string
|
|
32
|
+
Name string
|
|
33
|
+
Summary string
|
|
34
|
+
Slides Slides
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Slides is the slide metadata stored on the idea row (D22).
|
|
38
|
+
type Slides struct {
|
|
39
|
+
ObjectKey string // slides/{ideaId}/{uuid}.{ext}
|
|
40
|
+
Ext string // pdf | pptx
|
|
41
|
+
Size int64
|
|
42
|
+
SHA256 string // lower-case hex
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Idea is a row of idea joined with its Leader's public fields.
|
|
46
|
+
type Idea struct {
|
|
47
|
+
ID uuid.UUID
|
|
48
|
+
Category string
|
|
49
|
+
Org string
|
|
50
|
+
Direction string
|
|
51
|
+
Name string
|
|
52
|
+
Summary string
|
|
53
|
+
LeaderID uuid.UUID
|
|
54
|
+
LeaderEmpNo string
|
|
55
|
+
LeaderName string
|
|
56
|
+
Slides Slides
|
|
57
|
+
SlidesUpdatedAt time.Time
|
|
58
|
+
Version uint32
|
|
59
|
+
CreatedAt time.Time
|
|
60
|
+
UpdatedAt time.Time
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// InsertIdea inserts an Idea in the caller's transaction. A unique-key
|
|
64
|
+
// violation on uq_idea_leader_name is returned as the driver error; the
|
|
65
|
+
// service maps it with db.DuplicateKey to IDEA_DUPLICATE.
|
|
66
|
+
func InsertIdea(ctx context.Context, tx *sql.Tx, n NewIdea) error {
|
|
67
|
+
_, err := tx.ExecContext(ctx,
|
|
68
|
+
`INSERT INTO idea (id, leader_id, category, org, direction, name, summary,
|
|
69
|
+
slides_object_key, slides_ext, slides_size, slides_sha256)
|
|
70
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
71
|
+
n.ID.String(), n.LeaderID.String(), n.Category, n.Org, n.Direction, n.Name, n.Summary,
|
|
72
|
+
n.Slides.ObjectKey, n.Slides.Ext, n.Slides.Size, n.Slides.SHA256)
|
|
73
|
+
if err != nil {
|
|
74
|
+
return fmt.Errorf("insert idea: %w", err)
|
|
75
|
+
}
|
|
76
|
+
return nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// VisibleWhere is the one visibility predicate (PRD 6.6: an Idea is visible to
|
|
80
|
+
// its Leader, its approved Members and Admins). Every read of an Idea, single
|
|
81
|
+
// or list, is built on it; no other code decides who may see an Idea.
|
|
82
|
+
// Arguments, in order: is_admin (0/1, CAST AS SIGNED in sqlc terms, rule R4),
|
|
83
|
+
// viewer user id. It assumes the idea table is aliased i.
|
|
84
|
+
//
|
|
85
|
+
// TODO(B5-01): add the approved-Member branch
|
|
86
|
+
//
|
|
87
|
+
// OR EXISTS (SELECT 1 FROM idea_member m WHERE m.idea_id = i.id
|
|
88
|
+
// AND m.user_id = ? AND m.status = 'ACCEPTED')
|
|
89
|
+
//
|
|
90
|
+
// when the idea_member table exists. Until then Members cannot exist.
|
|
91
|
+
const VisibleWhere = `(? = 1 OR i.leader_id = ?)`
|
|
92
|
+
|
|
93
|
+
const selectIdea = `
|
|
94
|
+
SELECT i.id, i.category, i.org, i.direction, i.name, i.summary,
|
|
95
|
+
i.leader_id, u.emp_no, u.name,
|
|
96
|
+
i.slides_object_key, i.slides_ext, i.slides_size, i.slides_sha256, i.slides_updated_at,
|
|
97
|
+
i.version, i.created_at, i.updated_at
|
|
98
|
+
FROM idea i
|
|
99
|
+
JOIN app_user u ON u.id = i.leader_id`
|
|
100
|
+
|
|
101
|
+
// GetVisible is the single visibility query for one Idea. A missing Idea and
|
|
102
|
+
// an Idea the viewer may not see both return ErrNotFound.
|
|
103
|
+
func GetVisible(ctx context.Context, q Querier, id, viewer uuid.UUID, isAdmin bool) (Idea, error) {
|
|
104
|
+
var (
|
|
105
|
+
out Idea
|
|
106
|
+
sid, lid, vid string
|
|
107
|
+
admin int
|
|
108
|
+
)
|
|
109
|
+
admin = 0
|
|
110
|
+
if isAdmin {
|
|
111
|
+
admin = 1
|
|
112
|
+
}
|
|
113
|
+
vid = viewer.String()
|
|
114
|
+
err := q.QueryRowContext(ctx, selectIdea+` WHERE i.id = ? AND `+VisibleWhere,
|
|
115
|
+
id.String(), admin, vid).Scan(
|
|
116
|
+
&sid, &out.Category, &out.Org, &out.Direction, &out.Name, &out.Summary,
|
|
117
|
+
&lid, &out.LeaderEmpNo, &out.LeaderName,
|
|
118
|
+
&out.Slides.ObjectKey, &out.Slides.Ext, &out.Slides.Size, &out.Slides.SHA256, &out.SlidesUpdatedAt,
|
|
119
|
+
&out.Version, &out.CreatedAt, &out.UpdatedAt)
|
|
120
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
121
|
+
return Idea{}, ErrNotFound
|
|
122
|
+
}
|
|
123
|
+
if err != nil {
|
|
124
|
+
return Idea{}, fmt.Errorf("select idea: %w", err)
|
|
125
|
+
}
|
|
126
|
+
if out.ID, err = uuid.Parse(sid); err != nil {
|
|
127
|
+
return Idea{}, fmt.Errorf("idea id: %w", err)
|
|
128
|
+
}
|
|
129
|
+
if out.LeaderID, err = uuid.Parse(lid); err != nil {
|
|
130
|
+
return Idea{}, fmt.Errorf("idea leader id: %w", err)
|
|
131
|
+
}
|
|
132
|
+
return out, nil
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ErrNoLeader is returned by LeaderName when the user row is gone, which a
|
|
136
|
+
// valid session rules out; the caller treats it as an internal error.
|
|
137
|
+
var ErrNoLeader = errors.New("leader account not found")
|
|
138
|
+
|
|
139
|
+
// LeaderName returns the account name of user id: the expected filename takes
|
|
140
|
+
// the Leader's name from the account, never from the form (PRD 6.6).
|
|
141
|
+
func LeaderName(ctx context.Context, q Querier, id uuid.UUID) (string, error) {
|
|
142
|
+
var name string
|
|
143
|
+
err := q.QueryRowContext(ctx, `SELECT name FROM app_user WHERE id = ?`, id.String()).Scan(&name)
|
|
144
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
145
|
+
return "", ErrNoLeader
|
|
146
|
+
}
|
|
147
|
+
if err != nil {
|
|
148
|
+
return "", fmt.Errorf("select leader name: %w", err)
|
|
149
|
+
}
|
|
150
|
+
return name, nil
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// NameTaken is the read-only duplicate pre-check of create: it saves an upload
|
|
154
|
+
// in the common case, while uq_idea_leader_name stays authoritative.
|
|
155
|
+
func NameTaken(ctx context.Context, q Querier, leader uuid.UUID, name string) (bool, error) {
|
|
156
|
+
var one int
|
|
157
|
+
err := q.QueryRowContext(ctx, `SELECT 1 FROM idea WHERE leader_id = ? AND name = ?`,
|
|
158
|
+
leader.String(), name).Scan(&one)
|
|
159
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
160
|
+
return false, nil
|
|
161
|
+
}
|
|
162
|
+
if err != nil {
|
|
163
|
+
return false, fmt.Errorf("select idea name: %w", err)
|
|
164
|
+
}
|
|
165
|
+
return true, nil
|
|
166
|
+
}
|