Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a371045b41 | |||
| da867406e2 | |||
| 21c7aa31ea | |||
| 4def59c86d | |||
| d345839634 | |||
| 7d0ea4e388 |
@@ -266,6 +266,16 @@ WHERE p.id = $1`
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// RecordView appends a row to activity.pool_views. viewed_at defaults to
|
||||
// statement_timestamp(), so each call records a distinct view in the history.
|
||||
func (r *PoolRepo) RecordView(ctx context.Context, poolID uuid.UUID, userID int16) error {
|
||||
const query = `INSERT INTO activity.pool_views (pool_id, user_id) VALUES ($1, $2)`
|
||||
if _, err := connOrTx(ctx, r.pool).Exec(ctx, query, poolID, userID); err != nil {
|
||||
return fmt.Errorf("PoolRepo.RecordView: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -272,7 +272,7 @@ func (r *TagRepo) Create(ctx context.Context, t *domain.Tag) (*domain.Tag, error
|
||||
const query = `
|
||||
WITH ins AS (
|
||||
INSERT INTO data.tags (name, notes, color, category_id, metadata, creator_id, is_public)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
)
|
||||
SELECT
|
||||
@@ -321,7 +321,7 @@ WITH upd AS (
|
||||
UPDATE data.tags SET
|
||||
name = $2,
|
||||
notes = $3,
|
||||
color = $4,
|
||||
color = NULLIF($4, ''),
|
||||
category_id = $5,
|
||||
metadata = COALESCE($6, metadata),
|
||||
is_public = $7
|
||||
|
||||
@@ -160,6 +160,25 @@ func (h *PoolHandler) Get(c *gin.Context) {
|
||||
respondJSON(c, http.StatusOK, toPoolJSON(*p))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /pools/:pool_id/views
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RecordView logs that the current user viewed the pool (activity.pool_views).
|
||||
func (h *PoolHandler) RecordView(c *gin.Context) {
|
||||
id, ok := parsePoolID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.poolSvc.RecordView(c.Request.Context(), id); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /pools/:pool_id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -141,6 +141,7 @@ func NewRouter(
|
||||
pools.GET("/:pool_id", poolHandler.Get)
|
||||
pools.PATCH("/:pool_id", poolHandler.Update)
|
||||
pools.DELETE("/:pool_id", poolHandler.Delete)
|
||||
pools.POST("/:pool_id/views", poolHandler.RecordView)
|
||||
|
||||
// Sub-routes registered before /:pool_id/files to avoid param conflicts.
|
||||
pools.POST("/:pool_id/files/remove", poolHandler.RemoveFiles)
|
||||
|
||||
@@ -857,6 +857,75 @@ func TestTagSortByCategoryThenName(t *testing.T) {
|
||||
assert.Equal(t, []string{"ant", "zebra", "mid", "solo"}, names)
|
||||
}
|
||||
|
||||
// TestRecordPoolView verifies that viewing a pool is logged (POST .../views),
|
||||
// is repeatable (view history, not a unique flag), and 404s for unknown pools.
|
||||
func TestRecordPoolView(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
h := setupSuite(t)
|
||||
ctx := context.Background()
|
||||
adminToken := h.login("admin", "admin")
|
||||
|
||||
resp := h.doJSON("POST", "/pools", map[string]any{"name": "trip"}, adminToken)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||
var pool map[string]any
|
||||
resp.decode(t, &pool)
|
||||
poolID := pool["id"].(string)
|
||||
|
||||
resp = h.doJSON("POST", "/pools/"+poolID+"/views", nil, adminToken)
|
||||
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
||||
|
||||
// Viewing again logs another history row, not a conflict.
|
||||
resp = h.doJSON("POST", "/pools/"+poolID+"/views", nil, adminToken)
|
||||
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
||||
|
||||
var n int
|
||||
require.NoError(t, h.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM activity.pool_views WHERE pool_id = $1`, poolID).Scan(&n))
|
||||
assert.Equal(t, 2, n, "each view should add a history row")
|
||||
|
||||
// Unknown pool id → 404.
|
||||
resp = h.doJSON("POST", "/pools/00000000-0000-0000-0000-000000000000/views", nil, adminToken)
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
// TestTagColorOptional verifies a tag can be created without a colour (stored as
|
||||
// NULL rather than the colour input's default) and that an existing colour can
|
||||
// be cleared back to none.
|
||||
func TestTagColorOptional(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
h := setupSuite(t)
|
||||
adminToken := h.login("admin", "admin")
|
||||
|
||||
// Created without a colour → color is null.
|
||||
resp := h.doJSON("POST", "/tags", map[string]any{"name": "plain"}, adminToken)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||
var plain map[string]any
|
||||
resp.decode(t, &plain)
|
||||
assert.Nil(t, plain["color"], "tag created without a colour should have null color")
|
||||
|
||||
// Created with a colour → kept verbatim.
|
||||
resp = h.doJSON("POST", "/tags", map[string]any{"name": "red", "color": "aabbcc"}, adminToken)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||
var red map[string]any
|
||||
resp.decode(t, &red)
|
||||
assert.Equal(t, "aabbcc", red["color"])
|
||||
redID := red["id"].(string)
|
||||
|
||||
// Clearing the colour (color: null) must store NULL — an empty string would
|
||||
// violate the hex CHECK constraint and fail the update.
|
||||
resp = h.doJSON("PATCH", "/tags/"+redID, map[string]any{"color": nil}, adminToken)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
var cleared map[string]any
|
||||
resp.decode(t, &cleared)
|
||||
assert.Nil(t, cleared["color"], "cleared colour should be null")
|
||||
}
|
||||
|
||||
// TestBulkTagAutoRule verifies the bulk add path also applies then_tags.
|
||||
func TestBulkTagAutoRule(t *testing.T) {
|
||||
if testing.Short() {
|
||||
|
||||
@@ -129,6 +129,9 @@ type PoolRepo interface {
|
||||
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
||||
// Reorder sets the full ordered sequence of file IDs in the pool.
|
||||
Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
||||
|
||||
// RecordView appends a view-history row (activity.pool_views) for the user.
|
||||
RecordView(ctx context.Context, poolID uuid.UUID, userID int16) error
|
||||
}
|
||||
|
||||
// UserRepo is the persistence interface for users.
|
||||
|
||||
@@ -82,6 +82,16 @@ func (s *PoolService) authorizeView(ctx context.Context, poolID uuid.UUID) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordView appends a view-history entry for the current user, enforcing view
|
||||
// ACL (you can only record a view of a pool you may see).
|
||||
func (s *PoolService) RecordView(ctx context.Context, id uuid.UUID) error {
|
||||
userID, _, _ := domain.UserFromContext(ctx)
|
||||
if err := s.authorizeView(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.pools.RecordView(ctx, id, userID)
|
||||
}
|
||||
|
||||
// authorizeEdit returns nil if the caller may edit the pool, else ErrForbidden
|
||||
// (or ErrNotFound if the pool does not exist).
|
||||
func (s *PoolService) authorizeEdit(ctx context.Context, poolID uuid.UUID) error {
|
||||
|
||||
@@ -46,8 +46,8 @@ export class ApiError extends Error {
|
||||
let refreshPromise: Promise<void> | null = null;
|
||||
|
||||
async function refreshTokens(): Promise<void> {
|
||||
const { refreshToken } = get(authStore);
|
||||
if (!refreshToken) {
|
||||
const attempted = get(authStore).refreshToken;
|
||||
if (!attempted) {
|
||||
endSession();
|
||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
||||
}
|
||||
@@ -55,10 +55,15 @@ async function refreshTokens(): Promise<void> {
|
||||
const res = await fetch(`${BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
body: JSON.stringify({ refresh_token: attempted })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Refresh tokens rotate, so another tab may have already refreshed and
|
||||
// rotated ours out. If a newer token has since synced in from that tab (via
|
||||
// the auth store's storage listener), adopt it and let the caller retry
|
||||
// rather than ending a session that's actually still alive.
|
||||
if (get(authStore).refreshToken !== attempted) return;
|
||||
endSession();
|
||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
||||
}
|
||||
|
||||
@@ -25,10 +25,35 @@ function loadStored(): AuthState {
|
||||
|
||||
export const authStore = writable<AuthState>(loadStored());
|
||||
|
||||
// Persist on change. Compare first so a value that just arrived from another tab
|
||||
// (applied by the storage listener below) isn't written straight back, which
|
||||
// would risk a storage-event echo between tabs.
|
||||
authStore.subscribe((state) => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('auth', JSON.stringify(state));
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const serialized = JSON.stringify(state);
|
||||
if (localStorage.getItem('auth') !== serialized) {
|
||||
localStorage.setItem('auth', serialized);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep tabs in sync. Refresh tokens rotate on every use (each refresh deletes the
|
||||
// old session server-side), so when one tab logs in, refreshes, or logs out, the
|
||||
// others must pick up the new tokens — or the cleared session — immediately.
|
||||
// Otherwise a second tab would later refresh with a token that's already been
|
||||
// rotated away and get bounced to the login screen.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key !== 'auth') return;
|
||||
let next: AuthState = initial;
|
||||
if (e.newValue) {
|
||||
try {
|
||||
next = (JSON.parse(e.newValue) as AuthState) ?? initial;
|
||||
} catch {
|
||||
next = initial;
|
||||
}
|
||||
}
|
||||
authStore.set(next);
|
||||
});
|
||||
}
|
||||
|
||||
export const isAuthenticated = derived(authStore, ($auth) => !!$auth.accessToken);
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
notes = p.notes ?? '';
|
||||
isPublic = p.is_public ?? false;
|
||||
loaded = true;
|
||||
// Log the view (activity.pool_views). Fire-and-forget — never block or
|
||||
// fail the page over view tracking.
|
||||
void api.post(`/pools/${id}/views`).catch(() => {});
|
||||
})
|
||||
.catch((e) => {
|
||||
loadError = e instanceof ApiError ? e.message : 'Failed to load pool';
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
|
||||
let name = $state('');
|
||||
let notes = $state('');
|
||||
// A native <input type="color"> always holds a value, so a separate flag tracks
|
||||
// whether the tag has a colour at all — letting it be cleared back to none.
|
||||
let hasColor = $state(false);
|
||||
let color = $state('#444455');
|
||||
let categoryId = $state('');
|
||||
let isPublic = $state(false);
|
||||
@@ -43,6 +46,7 @@
|
||||
|
||||
name = t.name ?? '';
|
||||
notes = t.notes ?? '';
|
||||
hasColor = !!t.color;
|
||||
color = t.color ? `#${t.color}` : '#444455';
|
||||
categoryId = t.category_id ?? '';
|
||||
isPublic = t.is_public ?? false;
|
||||
@@ -61,7 +65,7 @@
|
||||
await api.patch(`/tags/${tagId}`, {
|
||||
name: name.trim(),
|
||||
notes: notes.trim() || null,
|
||||
color: color.slice(1),
|
||||
color: hasColor ? color.slice(1) : null,
|
||||
category_id: categoryId || null,
|
||||
is_public: isPublic
|
||||
});
|
||||
@@ -139,8 +143,18 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="field color-field">
|
||||
<label class="label" for="color">Color</label>
|
||||
<input id="color" class="color-input" type="color" bind:value={color} />
|
||||
<label class="label color-label">
|
||||
<input type="checkbox" class="color-check" bind:checked={hasColor} />
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
id="color"
|
||||
class="color-input"
|
||||
type="color"
|
||||
bind:value={color}
|
||||
disabled={!hasColor}
|
||||
aria-label="Tag color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -339,6 +353,18 @@
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.color-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-check {
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.color-input {
|
||||
width: 50px;
|
||||
height: 36px;
|
||||
@@ -349,6 +375,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-input:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
let name = $state('');
|
||||
let notes = $state('');
|
||||
// A native <input type="color"> always holds a value, so a separate flag tracks
|
||||
// whether the tag should have a color at all. Off by default → no colour unless
|
||||
// the user opts in (otherwise the tag falls back to its category / the default).
|
||||
let hasColor = $state(false);
|
||||
let color = $state('#444455');
|
||||
let categoryId = $state('');
|
||||
let isPublic = $state(false);
|
||||
@@ -27,7 +31,7 @@
|
||||
await api.post('/tags', {
|
||||
name: name.trim(),
|
||||
notes: notes.trim() || null,
|
||||
color: color.slice(1), // strip #
|
||||
color: hasColor ? color.slice(1) : null, // strip #; null = no colour
|
||||
category_id: categoryId || null,
|
||||
is_public: isPublic
|
||||
});
|
||||
@@ -86,8 +90,18 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="field color-field">
|
||||
<label class="label" for="color">Color</label>
|
||||
<input id="color" class="color-input" type="color" bind:value={color} />
|
||||
<label class="label color-label">
|
||||
<input type="checkbox" class="color-check" bind:checked={hasColor} />
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
id="color"
|
||||
class="color-input"
|
||||
type="color"
|
||||
bind:value={color}
|
||||
disabled={!hasColor}
|
||||
aria-label="Tag color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,6 +249,18 @@
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.color-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-check {
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.color-input {
|
||||
width: 50px;
|
||||
height: 36px;
|
||||
@@ -245,6 +271,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-input:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -1151,6 +1151,18 @@ paths:
|
||||
'204':
|
||||
description: Pool deleted
|
||||
|
||||
/pools/{pool_id}/views:
|
||||
post:
|
||||
tags: [Pools]
|
||||
summary: Record that the current user viewed the pool
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/pool_id'
|
||||
responses:
|
||||
'204':
|
||||
description: View recorded
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/pools/{pool_id}/files:
|
||||
get:
|
||||
tags: [Pools, Files]
|
||||
|
||||
Reference in New Issue
Block a user