Compare commits
3 Commits
v2.6.93
...
734ba68eed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
734ba68eed | ||
|
|
708f8bcd6f | ||
|
|
7009b24568 |
@@ -199,6 +199,7 @@ func run() error {
|
||||
BookWriter: store,
|
||||
AIJobStore: store,
|
||||
BookAdminStore: store,
|
||||
NotificationStore: store,
|
||||
Log: log,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1279,6 +1279,10 @@ func (s *Server) handleTranslationRead(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Translated chapter content is immutable once generated — cache aggressively.
|
||||
// The browser and any intermediary (CDN, SvelteKit fetch cache) can reuse this
|
||||
// response for 1 hour without hitting MinIO again.
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
|
||||
writeJSON(w, 0, map[string]string{"html": buf.String(), "lang": lang})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ package backend
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// handleDismissNotification handles DELETE /api/notifications/{id}.
|
||||
@@ -14,12 +12,11 @@ func (s *Server) handleDismissNotification(w http.ResponseWriter, r *http.Reques
|
||||
jsonError(w, http.StatusBadRequest, "notification id required")
|
||||
return
|
||||
}
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
if err := store.DeleteNotification(r.Context(), id); err != nil {
|
||||
if err := s.deps.NotificationStore.DeleteNotification(r.Context(), id); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "dismiss notification: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -33,12 +30,11 @@ func (s *Server) handleClearAllNotifications(w http.ResponseWriter, r *http.Requ
|
||||
jsonError(w, http.StatusBadRequest, "user_id required")
|
||||
return
|
||||
}
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
if err := store.ClearAllNotifications(r.Context(), userID); err != nil {
|
||||
if err := s.deps.NotificationStore.ClearAllNotifications(r.Context(), userID); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "clear notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -52,12 +48,11 @@ func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.R
|
||||
jsonError(w, http.StatusBadRequest, "user_id required")
|
||||
return
|
||||
}
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
if err := store.MarkAllNotificationsRead(r.Context(), userID); err != nil {
|
||||
if err := s.deps.NotificationStore.MarkAllNotificationsRead(r.Context(), userID); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "mark all read: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -80,13 +75,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := store.ListNotifications(r.Context(), userID, 50)
|
||||
items, err := s.deps.NotificationStore.ListNotifications(r.Context(), userID, 50)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error())
|
||||
return
|
||||
@@ -97,7 +91,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
for _, item := range items {
|
||||
b, _ := json.Marshal(item)
|
||||
var n notification
|
||||
json.Unmarshal(b, &n)
|
||||
json.Unmarshal(b, &n) //nolint:errcheck
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
|
||||
@@ -111,16 +105,15 @@ func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.MarkNotificationRead(r.Context(), id); err != nil {
|
||||
if err := s.deps.NotificationStore.MarkNotificationRead(r.Context(), id); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +313,8 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
// Mark job as done in PB, persisting results so the Review button works.
|
||||
// Use context.Background() — r.Context() may be cancelled if the SSE client
|
||||
// disconnected before processing finished, which would silently drop results.
|
||||
if jobID != "" && s.deps.AIJobStore != nil {
|
||||
status := domain.TaskStatusDone
|
||||
if jobCtx.Err() != nil {
|
||||
@@ -321,7 +323,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
resultsJSON, _ := json.Marshal(allResults)
|
||||
finalPayload := fmt.Sprintf(`{"pattern":%q,"slug":%q,"results":%s}`,
|
||||
req.Pattern, req.Slug, string(resultsJSON))
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
"status": string(status),
|
||||
"items_done": chaptersDone,
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
|
||||
@@ -94,6 +94,10 @@ type Dependencies struct {
|
||||
// BookAdminStore provides admin-only operations: archive, unarchive, hard-delete.
|
||||
// If nil, the admin book management endpoints return 503.
|
||||
BookAdminStore bookstore.BookAdminStore
|
||||
// NotificationStore manages per-user in-app notifications.
|
||||
// Always wired directly to *storage.Store (not the Asynq wrapper) so
|
||||
// notification endpoints work regardless of whether Redis/Asynq is in use.
|
||||
NotificationStore bookstore.NotificationStore
|
||||
// Log is the structured logger.
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
@@ -247,3 +247,14 @@ type ImportFileStore interface {
|
||||
// GetImportChapters retrieves the pre-parsed chapters JSON.
|
||||
GetImportChapters(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
// NotificationStore manages per-user in-app notifications.
|
||||
// Always wired directly to the concrete *storage.Store so it works
|
||||
// regardless of whether the Asynq task-queue wrapper is in use.
|
||||
type NotificationStore interface {
|
||||
ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error)
|
||||
MarkNotificationRead(ctx context.Context, id string) error
|
||||
MarkAllNotificationsRead(ctx context.Context, userID string) error
|
||||
DeleteNotification(ctx context.Context, id string) error
|
||||
ClearAllNotifications(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
@@ -773,7 +773,7 @@ func (s *Store) CreateNotification(ctx context.Context, userID, title, message,
|
||||
|
||||
// ListNotifications returns notifications for a user.
|
||||
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) {
|
||||
filter := fmt.Sprintf("user_id='%s'", userID)
|
||||
filter := fmt.Sprintf(`user_id="%s"`, userID)
|
||||
items, err := s.pb.listAll(ctx, "notifications", filter, "-created")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -805,7 +805,7 @@ func (s *Store) DeleteNotification(ctx context.Context, id string) error {
|
||||
|
||||
// ClearAllNotifications deletes all notifications for a user.
|
||||
func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error {
|
||||
filter := fmt.Sprintf("user_id='%s'", userID)
|
||||
filter := fmt.Sprintf(`user_id="%s"`, userID)
|
||||
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ClearAllNotifications list: %w", err)
|
||||
@@ -823,7 +823,7 @@ func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error
|
||||
|
||||
// MarkAllNotificationsRead marks all notifications for a user as read.
|
||||
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
|
||||
filter := fmt.Sprintf("user_id='%s'&&read=false", userID)
|
||||
filter := fmt.Sprintf(`user_id="%s"&&read=false`, userID)
|
||||
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("MarkAllNotificationsRead list: %w", err)
|
||||
|
||||
@@ -6,8 +6,7 @@ export type { AIJob };
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
// Stream jobs so navigation is instant; list populates a moment later.
|
||||
const jobs = listAIJobs().catch((e): AIJob[] => {
|
||||
const jobs = await listAIJobs().catch((e): AIJob[] => {
|
||||
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
let jobs = $state<AIJob[]>([]);
|
||||
|
||||
// Resolve streamed promise on load and on server reloads (invalidateAll)
|
||||
// data.jobs is a plain AIJob[] (resolved on server); re-sync on invalidateAll
|
||||
$effect(() => {
|
||||
data.jobs.then((resolved) => { jobs = resolved; });
|
||||
jobs = data.jobs;
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
|
||||
@@ -23,9 +23,12 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
return new Response(null, { status: res.status });
|
||||
}
|
||||
const data = await res.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
// Forward the immutable cache header from the backend so browsers and CDNs
|
||||
// can cache translated chapter content without hitting MinIO on every load.
|
||||
const cc = res.headers.get('Cache-Control');
|
||||
if (cc) headers['Cache-Control'] = cc;
|
||||
return new Response(JSON.stringify(data), { headers });
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, locals }) => {
|
||||
|
||||
Reference in New Issue
Block a user