Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a371045b41 | |||
| da867406e2 | |||
| 21c7aa31ea | |||
| 4def59c86d | |||
| d345839634 | |||
| 7d0ea4e388 |
@@ -266,6 +266,16 @@ WHERE p.id = $1`
|
|||||||
return &p, nil
|
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
|
// Create
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ func (r *TagRepo) Create(ctx context.Context, t *domain.Tag) (*domain.Tag, error
|
|||||||
const query = `
|
const query = `
|
||||||
WITH ins AS (
|
WITH ins AS (
|
||||||
INSERT INTO data.tags (name, notes, color, category_id, metadata, creator_id, is_public)
|
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 *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -321,7 +321,7 @@ WITH upd AS (
|
|||||||
UPDATE data.tags SET
|
UPDATE data.tags SET
|
||||||
name = $2,
|
name = $2,
|
||||||
notes = $3,
|
notes = $3,
|
||||||
color = $4,
|
color = NULLIF($4, ''),
|
||||||
category_id = $5,
|
category_id = $5,
|
||||||
metadata = COALESCE($6, metadata),
|
metadata = COALESCE($6, metadata),
|
||||||
is_public = $7
|
is_public = $7
|
||||||
|
|||||||
@@ -160,6 +160,25 @@ func (h *PoolHandler) Get(c *gin.Context) {
|
|||||||
respondJSON(c, http.StatusOK, toPoolJSON(*p))
|
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
|
// PATCH /pools/:pool_id
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ func NewRouter(
|
|||||||
pools.GET("/:pool_id", poolHandler.Get)
|
pools.GET("/:pool_id", poolHandler.Get)
|
||||||
pools.PATCH("/:pool_id", poolHandler.Update)
|
pools.PATCH("/:pool_id", poolHandler.Update)
|
||||||
pools.DELETE("/:pool_id", poolHandler.Delete)
|
pools.DELETE("/:pool_id", poolHandler.Delete)
|
||||||
|
pools.POST("/:pool_id/views", poolHandler.RecordView)
|
||||||
|
|
||||||
// Sub-routes registered before /:pool_id/files to avoid param conflicts.
|
// Sub-routes registered before /:pool_id/files to avoid param conflicts.
|
||||||
pools.POST("/:pool_id/files/remove", poolHandler.RemoveFiles)
|
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)
|
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.
|
// TestBulkTagAutoRule verifies the bulk add path also applies then_tags.
|
||||||
func TestBulkTagAutoRule(t *testing.T) {
|
func TestBulkTagAutoRule(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ type PoolRepo interface {
|
|||||||
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
||||||
// Reorder sets the full ordered sequence of file IDs in the pool.
|
// Reorder sets the full ordered sequence of file IDs in the pool.
|
||||||
Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
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.
|
// UserRepo is the persistence interface for users.
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ func (s *PoolService) authorizeView(ctx context.Context, poolID uuid.UUID) error
|
|||||||
return nil
|
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
|
// authorizeEdit returns nil if the caller may edit the pool, else ErrForbidden
|
||||||
// (or ErrNotFound if the pool does not exist).
|
// (or ErrNotFound if the pool does not exist).
|
||||||
func (s *PoolService) authorizeEdit(ctx context.Context, poolID uuid.UUID) error {
|
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;
|
let refreshPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
async function refreshTokens(): Promise<void> {
|
async function refreshTokens(): Promise<void> {
|
||||||
const { refreshToken } = get(authStore);
|
const attempted = get(authStore).refreshToken;
|
||||||
if (!refreshToken) {
|
if (!attempted) {
|
||||||
endSession();
|
endSession();
|
||||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
throw new ApiError(401, 'unauthorized', 'Session expired');
|
||||||
}
|
}
|
||||||
@@ -55,10 +55,15 @@ async function refreshTokens(): Promise<void> {
|
|||||||
const res = await fetch(`${BASE}/auth/refresh`, {
|
const res = await fetch(`${BASE}/auth/refresh`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ refresh_token: refreshToken })
|
body: JSON.stringify({ refresh_token: attempted })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
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();
|
endSession();
|
||||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
throw new ApiError(401, 'unauthorized', 'Session expired');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,35 @@ function loadStored(): AuthState {
|
|||||||
|
|
||||||
export const authStore = writable<AuthState>(loadStored());
|
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) => {
|
authStore.subscribe((state) => {
|
||||||
if (typeof localStorage !== 'undefined') {
|
if (typeof localStorage === 'undefined') return;
|
||||||
localStorage.setItem('auth', JSON.stringify(state));
|
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);
|
export const isAuthenticated = derived(authStore, ($auth) => !!$auth.accessToken);
|
||||||
|
|||||||
@@ -86,6 +86,9 @@
|
|||||||
notes = p.notes ?? '';
|
notes = p.notes ?? '';
|
||||||
isPublic = p.is_public ?? false;
|
isPublic = p.is_public ?? false;
|
||||||
loaded = true;
|
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) => {
|
.catch((e) => {
|
||||||
loadError = e instanceof ApiError ? e.message : 'Failed to load pool';
|
loadError = e instanceof ApiError ? e.message : 'Failed to load pool';
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
|
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let notes = $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 color = $state('#444455');
|
||||||
let categoryId = $state('');
|
let categoryId = $state('');
|
||||||
let isPublic = $state(false);
|
let isPublic = $state(false);
|
||||||
@@ -43,6 +46,7 @@
|
|||||||
|
|
||||||
name = t.name ?? '';
|
name = t.name ?? '';
|
||||||
notes = t.notes ?? '';
|
notes = t.notes ?? '';
|
||||||
|
hasColor = !!t.color;
|
||||||
color = t.color ? `#${t.color}` : '#444455';
|
color = t.color ? `#${t.color}` : '#444455';
|
||||||
categoryId = t.category_id ?? '';
|
categoryId = t.category_id ?? '';
|
||||||
isPublic = t.is_public ?? false;
|
isPublic = t.is_public ?? false;
|
||||||
@@ -61,7 +65,7 @@
|
|||||||
await api.patch(`/tags/${tagId}`, {
|
await api.patch(`/tags/${tagId}`, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
color: color.slice(1),
|
color: hasColor ? color.slice(1) : null,
|
||||||
category_id: categoryId || null,
|
category_id: categoryId || null,
|
||||||
is_public: isPublic
|
is_public: isPublic
|
||||||
});
|
});
|
||||||
@@ -139,8 +143,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field color-field">
|
<div class="field color-field">
|
||||||
<label class="label" for="color">Color</label>
|
<label class="label color-label">
|
||||||
<input id="color" class="color-input" type="color" bind:value={color} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,6 +353,18 @@
|
|||||||
border-color: var(--color-accent);
|
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 {
|
.color-input {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -349,6 +375,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-input:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let notes = $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 color = $state('#444455');
|
||||||
let categoryId = $state('');
|
let categoryId = $state('');
|
||||||
let isPublic = $state(false);
|
let isPublic = $state(false);
|
||||||
@@ -27,7 +31,7 @@
|
|||||||
await api.post('/tags', {
|
await api.post('/tags', {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
color: color.slice(1), // strip #
|
color: hasColor ? color.slice(1) : null, // strip #; null = no colour
|
||||||
category_id: categoryId || null,
|
category_id: categoryId || null,
|
||||||
is_public: isPublic
|
is_public: isPublic
|
||||||
});
|
});
|
||||||
@@ -86,8 +90,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field color-field">
|
<div class="field color-field">
|
||||||
<label class="label" for="color">Color</label>
|
<label class="label color-label">
|
||||||
<input id="color" class="color-input" type="color" bind:value={color} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -235,6 +249,18 @@
|
|||||||
border-color: var(--color-accent);
|
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 {
|
.color-input {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -245,6 +271,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-input:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -1151,6 +1151,18 @@ paths:
|
|||||||
'204':
|
'204':
|
||||||
description: Pool deleted
|
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:
|
/pools/{pool_id}/files:
|
||||||
get:
|
get:
|
||||||
tags: [Pools, Files]
|
tags: [Pools, Files]
|
||||||
|
|||||||
Reference in New Issue
Block a user