mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-07-16 03:13:36 +00:00
Compare commits
10 Commits
claude/nlp
...
claude/pap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5621f120f | ||
|
|
61430317f4 | ||
|
|
03187ab456 | ||
|
|
9b5add1cdf | ||
|
|
3838bfe51a | ||
|
|
9c57dcaedb | ||
|
|
5680465e98 | ||
| 41352b05ec | |||
| 04882b2652 | |||
|
|
6ab7e35a6a |
11
.env.example
11
.env.example
@@ -57,6 +57,17 @@ AUTHENTIK_CLIENT_SECRET=
|
|||||||
# Leave blank to use the built-in rule-based tailoring instead of Claude.
|
# Leave blank to use the built-in rule-based tailoring instead of Claude.
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# ── Paperless-ngx integration (optional) ─────────────────────────────────────
|
||||||
|
# When enabled, published CVs are uploaded to your paperless-ngx instance and
|
||||||
|
# shared via paperless share links (with optional expiry). MinIO is still used
|
||||||
|
# for DOCX artifact storage; paperless handles the published PDF + sharing.
|
||||||
|
PAPERLESS_ENABLED=false
|
||||||
|
PAPERLESS_BASE_URL=http://localhost:8000
|
||||||
|
# API token — obtain via POST /api/token/ with your paperless credentials.
|
||||||
|
PAPERLESS_TOKEN=
|
||||||
|
# Comma-separated tag IDs to apply to uploaded CV documents (optional).
|
||||||
|
PAPERLESS_TAG_IDS=
|
||||||
|
|
||||||
# ── Demo mode ─────────────────────────────────────────────────────────────────
|
# ── Demo mode ─────────────────────────────────────────────────────────────────
|
||||||
# Set to true to enable standalone demo mode in the webapp.
|
# Set to true to enable standalone demo mode in the webapp.
|
||||||
# Demo mode uses hardcoded dummy data — no backend or DB required.
|
# Demo mode uses hardcoded dummy data — no backend or DB required.
|
||||||
|
|||||||
@@ -105,9 +105,9 @@ flowchart LR
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `API_BASE_URL` | Backend host consumed by the webapp build + runtime. | `http://cvfs-backend:8080` in Docker |
|
| `API_BASE_URL` | Backend host consumed by the webapp build + runtime. | `http://cvfs-backend:8080` in Docker |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Public origin for web routes. | `https://cv.alves.world` |
|
| `NEXT_PUBLIC_BASE_URL` | Public origin for web routes. | — |
|
||||||
| `AUTHENTIK_*` / `AUTH_OIDC_*` | OIDC issuer, audience, and client credentials. | required |
|
| `AUTHENTIK_*` / `AUTH_OIDC_*` | OIDC issuer, audience, and client credentials. | required |
|
||||||
| `MINIO_ENDPOINT` / `MINIO_BUCKET` | Object storage endpoint + bucket for artifacts. | `https://storage.cv.alves.world` / `resume-branches` |
|
| `MINIO_ENDPOINT` / `MINIO_BUCKET` | Object storage endpoint + bucket for artifacts. | — |
|
||||||
| `REDIS_URL` | Celery broker/result backend. | `redis://cvfs-redis:6379/0` |
|
| `REDIS_URL` | Celery broker/result backend. | `redis://cvfs-redis:6379/0` |
|
||||||
| `DATABASE_URL` | SQLAlchemy DSN for Postgres. | `postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches` |
|
| `DATABASE_URL` | SQLAlchemy DSN for Postgres. | `postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches` |
|
||||||
| `ANTHROPIC_API_KEY` | Enables AI tailoring jobs. | unset |
|
| `ANTHROPIC_API_KEY` | Enables AI tailoring jobs. | unset |
|
||||||
@@ -148,7 +148,7 @@ Use `bun x nx affected -t lint,test,build` before PRs to run fine-grained checks
|
|||||||
|
|
||||||
## Deployment notes
|
## Deployment notes
|
||||||
- **Docker images:** `docker/backend-fastapi.Dockerfile`, `docker/webapp.Dockerfile`, and `docker/worker.Dockerfile` bake env args for Dokploy. The web build now receives `API_BASE_URL` so rewrites point at the deployed backend.
|
- **Docker images:** `docker/backend-fastapi.Dockerfile`, `docker/webapp.Dockerfile`, and `docker/worker.Dockerfile` bake env args for Dokploy. The web build now receives `API_BASE_URL` so rewrites point at the deployed backend.
|
||||||
- **Dokploy:** see `docs/resume-branches/dokploy.md` for the API payload that provisions `cvfs-backend`, `cvfs-webapp`, Redis, Postgres, and MinIO on `cv.alves.world` / `api.cv.alves.world`.
|
- **Dokploy:** see `docs/resume-branches/dokploy.md` for the API payload that provisions `cvfs-backend`, `cvfs-webapp`, Redis, Postgres, and MinIO.
|
||||||
- **Storage:** `make lift.minio` mirrors the production bucket layout. Set `MINIO_ROOT_USER/MINIO_ROOT_PASSWORD` for parity. Public artifacts should only be published via the API to guarantee immutability + audit logging.
|
- **Storage:** `make lift.minio` mirrors the production bucket layout. Set `MINIO_ROOT_USER/MINIO_ROOT_PASSWORD` for parity. Public artifacts should only be published via the API to guarantee immutability + audit logging.
|
||||||
|
|
||||||
## Limitations & next steps
|
## Limitations & next steps
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -16,11 +17,13 @@ from app.schemas import (
|
|||||||
PublicAssetLookupResponse,
|
PublicAssetLookupResponse,
|
||||||
PublicAssetResponse,
|
PublicAssetResponse,
|
||||||
PublishRequest,
|
PublishRequest,
|
||||||
|
ShareLinkRequest,
|
||||||
)
|
)
|
||||||
from app.services.publication import publish_version
|
from app.services.publication import publish_version
|
||||||
from app.services.storage import storage_client
|
from app.services.storage import storage_client
|
||||||
from dlib.auth import AuthenticatedUser
|
from dlib.auth import AuthenticatedUser
|
||||||
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
|
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
|
||||||
|
from dlib.integrations.paperless import get_paperless_client
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/public", tags=["public"])
|
router = APIRouter(prefix="/public", tags=["public"])
|
||||||
@@ -48,6 +51,18 @@ async def _get_public_asset(session: AsyncSession, slug: str) -> PublicAsset:
|
|||||||
return asset
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
async def _assert_owner(session: AsyncSession, asset: PublicAsset, owner_id: str) -> None:
|
||||||
|
if not asset.version_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
stmt = (
|
||||||
|
select(CvVersion)
|
||||||
|
.join(CvVersion.document)
|
||||||
|
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == owner_id)
|
||||||
|
)
|
||||||
|
if not (await session.execute(stmt)).scalars().one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/publish", response_model=PublicAssetResponse)
|
@router.post("/publish", response_model=PublicAssetResponse)
|
||||||
async def publish(
|
async def publish(
|
||||||
payload: PublishRequest,
|
payload: PublishRequest,
|
||||||
@@ -60,12 +75,40 @@ async def publish(
|
|||||||
version_id=payload.version_id,
|
version_id=payload.version_id,
|
||||||
submission_id=payload.submission_id,
|
submission_id=payload.submission_id,
|
||||||
slug=payload.slug,
|
slug=payload.slug,
|
||||||
|
expires_at=payload.expires_at,
|
||||||
)
|
)
|
||||||
if not asset:
|
if not asset:
|
||||||
raise HTTPException(status_code=404, detail="Version or submission not found")
|
raise HTTPException(status_code=404, detail="Version or submission not found")
|
||||||
return _response_from_asset(asset)
|
return _response_from_asset(asset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{slug}/share-links", response_model=PublicAssetResponse)
|
||||||
|
async def create_share_link(
|
||||||
|
slug: str,
|
||||||
|
payload: ShareLinkRequest,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
asset = await _get_public_asset(session, slug)
|
||||||
|
await _assert_owner(session, asset, user.sub)
|
||||||
|
|
||||||
|
if not asset.paperless_document_id:
|
||||||
|
raise HTTPException(status_code=409, detail="Asset not synced to paperless")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
client = get_paperless_client(settings)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(status_code=503, detail="Paperless integration not enabled")
|
||||||
|
|
||||||
|
_, share_url = await asyncio.to_thread(
|
||||||
|
client.create_share_link, asset.paperless_document_id, payload.expiration_date
|
||||||
|
)
|
||||||
|
asset.paperless_share_slug = share_url.split("/share/")[-1]
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(asset)
|
||||||
|
return _response_from_asset(asset)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
|
@router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
|
||||||
async def get_analytics(
|
async def get_analytics(
|
||||||
slug: str,
|
slug: str,
|
||||||
@@ -73,17 +116,7 @@ async def get_analytics(
|
|||||||
user: AuthenticatedUser = Depends(get_current_user),
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
asset = await _get_public_asset(session, slug)
|
asset = await _get_public_asset(session, slug)
|
||||||
|
await _assert_owner(session, asset, user.sub)
|
||||||
if asset.version_id:
|
|
||||||
stmt = (
|
|
||||||
select(CvVersion)
|
|
||||||
.join(CvVersion.document)
|
|
||||||
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == user.sub)
|
|
||||||
)
|
|
||||||
if not (await session.execute(stmt)).scalars().one_or_none():
|
|
||||||
raise HTTPException(status_code=403, detail="Not authorized")
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=403, detail="Not authorized")
|
|
||||||
|
|
||||||
view_count = (
|
view_count = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
@@ -137,7 +170,11 @@ async def get_public_asset(slug: str, request: Request, session: AsyncSession =
|
|||||||
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
|
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
base = settings.public_base_url.rstrip("/")
|
base = settings.public_base_url.rstrip("/")
|
||||||
url = f"{base}/cv/{asset.slug}"
|
paperless_share_url = (
|
||||||
|
f"{settings.paperless_base_url}/share/{asset.paperless_share_slug}"
|
||||||
|
if settings.paperless_base_url and asset.paperless_share_slug
|
||||||
|
else None
|
||||||
|
)
|
||||||
return PublicAssetResponse(
|
return PublicAssetResponse(
|
||||||
id=asset.id,
|
id=asset.id,
|
||||||
slug=asset.slug,
|
slug=asset.slug,
|
||||||
@@ -146,5 +183,6 @@ def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
|
|||||||
created_at=asset.created_at,
|
created_at=asset.created_at,
|
||||||
version_id=asset.version_id,
|
version_id=asset.version_id,
|
||||||
submission_id=asset.submission_id,
|
submission_id=asset.submission_id,
|
||||||
url=url,
|
url=f"{base}/cv/{asset.slug}",
|
||||||
|
paperless_share_url=paperless_share_url,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
publish_domain: str = Field(default="cv.alves.world", alias="CV_PUBLIC_DOMAIN")
|
publish_domain: str = Field(default="cv.alves.world", alias="CV_PUBLIC_DOMAIN")
|
||||||
|
|
||||||
|
paperless_enabled: bool = Field(default=False, alias="PAPERLESS_ENABLED")
|
||||||
|
paperless_base_url: str | None = Field(default=None, alias="PAPERLESS_BASE_URL")
|
||||||
|
paperless_token: str | None = Field(default=None, alias="PAPERLESS_TOKEN")
|
||||||
|
paperless_tag_ids: list[int] = Field(default_factory=list, alias="PAPERLESS_TAG_IDS")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
extra = "ignore"
|
extra = "ignore"
|
||||||
@@ -67,13 +72,20 @@ class Settings(BaseSettings):
|
|||||||
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@field_validator("storage_endpoint_url", mode="before")
|
@field_validator("storage_endpoint_url", "paperless_base_url", "paperless_token", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _empty_endpoint_to_none(cls, value):
|
def _empty_str_to_none(cls, value):
|
||||||
if isinstance(value, str) and not value.strip():
|
if isinstance(value, str) and not value.strip():
|
||||||
return None
|
return None
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@field_validator("paperless_tag_ids", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _parse_tag_ids(cls, value):
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [int(v.strip()) for v in value.split(",") if v.strip()]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import enum
|
import enum
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
|
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -148,6 +148,8 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
|
|||||||
expires_at: Mapped[str | None] = mapped_column(
|
expires_at: Mapped[str | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
paperless_document_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
paperless_share_slug: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||||
|
|
||||||
submission: Mapped[Submission | None] = relationship(
|
submission: Mapped[Submission | None] = relationship(
|
||||||
"Submission", back_populates="public_asset"
|
"Submission", back_populates="public_asset"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from .cv import (
|
|||||||
PublicAssetLookupResponse,
|
PublicAssetLookupResponse,
|
||||||
PublicAssetResponse,
|
PublicAssetResponse,
|
||||||
PublishRequest,
|
PublishRequest,
|
||||||
|
ShareLinkRequest,
|
||||||
SubmissionCreateRequest,
|
SubmissionCreateRequest,
|
||||||
SubmissionResponse,
|
SubmissionResponse,
|
||||||
SubmissionStatusUpdateRequest,
|
SubmissionStatusUpdateRequest,
|
||||||
@@ -31,6 +32,7 @@ __all__ = [
|
|||||||
"SuggestionResponse",
|
"SuggestionResponse",
|
||||||
"SuggestionUpdateRequest",
|
"SuggestionUpdateRequest",
|
||||||
"PublishRequest",
|
"PublishRequest",
|
||||||
|
"ShareLinkRequest",
|
||||||
"PublicAssetResponse",
|
"PublicAssetResponse",
|
||||||
"PublicAssetLookupResponse",
|
"PublicAssetLookupResponse",
|
||||||
"PublicAssetAnalyticsResponse",
|
"PublicAssetAnalyticsResponse",
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ class PublishRequest(BaseModel):
|
|||||||
version_id: str | None = None
|
version_id: str | None = None
|
||||||
submission_id: str | None = None
|
submission_id: str | None = None
|
||||||
slug: str | None = None
|
slug: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShareLinkRequest(BaseModel):
|
||||||
|
expiration_date: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class PublicAssetResponse(BaseModel):
|
class PublicAssetResponse(BaseModel):
|
||||||
@@ -134,6 +139,7 @@ class PublicAssetResponse(BaseModel):
|
|||||||
version_id: str | None = None
|
version_id: str | None = None
|
||||||
submission_id: str | None = None
|
submission_id: str | None = None
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
|
paperless_share_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class PublicAssetLookupResponse(BaseModel):
|
class PublicAssetLookupResponse(BaseModel):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -7,7 +8,11 @@ from uuid import uuid4
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.models import CvDocument, CvVersion, PublicAsset, Submission
|
from app.models import CvDocument, CvVersion, PublicAsset, Submission
|
||||||
|
from app.services.storage import storage_client
|
||||||
|
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
|
||||||
|
from dlib.integrations.paperless import get_paperless_client
|
||||||
|
|
||||||
|
|
||||||
async def publish_version(
|
async def publish_version(
|
||||||
@@ -17,6 +22,7 @@ async def publish_version(
|
|||||||
version_id: str | None,
|
version_id: str | None,
|
||||||
submission_id: str | None,
|
submission_id: str | None,
|
||||||
slug: str | None,
|
slug: str | None,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
) -> PublicAsset | None:
|
) -> PublicAsset | None:
|
||||||
target_version: CvVersion | None = None
|
target_version: CvVersion | None = None
|
||||||
target_submission: Submission | None = None
|
target_submission: Submission | None = None
|
||||||
@@ -55,11 +61,27 @@ async def publish_version(
|
|||||||
slug=resolved_slug,
|
slug=resolved_slug,
|
||||||
artifact_key=target_version.artifact_docx_key,
|
artifact_key=target_version.artifact_docx_key,
|
||||||
is_public=True,
|
is_public=True,
|
||||||
expires_at=None,
|
expires_at=expires_at,
|
||||||
)
|
)
|
||||||
session.add(asset)
|
session.add(asset)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(asset)
|
await session.refresh(asset)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
client = get_paperless_client(settings)
|
||||||
|
if client:
|
||||||
|
docx = storage_client.download_bytes(target_version.artifact_docx_key)
|
||||||
|
blocks = target_version.structured_blocks or []
|
||||||
|
pdf = docx_bytes_to_pdf(generate_patched_docx(docx, blocks))
|
||||||
|
doc_id = await asyncio.to_thread(
|
||||||
|
client.upload_document, pdf, resolved_slug, settings.paperless_tag_ids or []
|
||||||
|
)
|
||||||
|
_, share_url = await asyncio.to_thread(client.create_share_link, doc_id, expires_at)
|
||||||
|
asset.paperless_document_id = doc_id
|
||||||
|
asset.paperless_share_slug = share_url.split("/share/")[-1]
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(asset)
|
||||||
|
|
||||||
return asset
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.7.0",
|
"@supabase/ssr": "^0.7.0",
|
||||||
"@supabase/supabase-js": "^2.57.4",
|
"@supabase/supabase-js": "^2.57.4",
|
||||||
"next": "15.5.2",
|
"next": "15.5.14",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
},
|
},
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.2",
|
"eslint-config-next": "15.5.14",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
},
|
},
|
||||||
@@ -117,25 +117,25 @@
|
|||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||||
|
|
||||||
"@next/env": ["@next/env@15.5.2", "", {}, "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg=="],
|
"@next/env": ["@next/env@15.5.14", "", {}, "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA=="],
|
||||||
|
|
||||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.2", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-lkLrRVxcftuOsJNhWatf1P2hNVfh98k/omQHrCEPPriUypR6RcS13IvLdIrEvkm9AH2Nu2YpR5vLqBuy6twH3Q=="],
|
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.14", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-ogBjgsFrPPz19abP3VwcYSahbkUOMMvJjxCOYWYndw+PydeMuLuB4XrvNkNutFrTjC9St2KFULRdKID8Sd/CMQ=="],
|
||||||
|
|
||||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ=="],
|
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg=="],
|
||||||
|
|
||||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ=="],
|
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA=="],
|
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g=="],
|
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q=="],
|
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g=="],
|
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw=="],
|
||||||
|
|
||||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg=="],
|
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g=="],
|
||||||
|
|
||||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q=="],
|
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg=="],
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
@@ -395,7 +395,7 @@
|
|||||||
|
|
||||||
"eslint": ["eslint@9.35.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.35.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg=="],
|
"eslint": ["eslint@9.35.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.35.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg=="],
|
||||||
|
|
||||||
"eslint-config-next": ["eslint-config-next@15.5.2", "", { "dependencies": { "@next/eslint-plugin-next": "15.5.2", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" } }, "sha512-3hPZghsLupMxxZ2ggjIIrat/bPniM2yRpsVPVM40rp8ZMzKWOJp2CGWn7+EzoV2ddkUr5fxNfHpF+wU1hGt/3g=="],
|
"eslint-config-next": ["eslint-config-next@15.5.14", "", { "dependencies": { "@next/eslint-plugin-next": "15.5.14", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-lmJ5F8ZgOYogq0qtH4L5SpxuASY2SPdOzqUprN2/56+P3GPsIpXaUWIJC66kYIH+yZdsM4nkHE5MIBP6s1NiBw=="],
|
||||||
|
|
||||||
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
|
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
|
||||||
|
|
||||||
@@ -635,7 +635,7 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
"next": ["next@15.5.2", "", { "dependencies": { "@next/env": "15.5.2", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.2", "@next/swc-darwin-x64": "15.5.2", "@next/swc-linux-arm64-gnu": "15.5.2", "@next/swc-linux-arm64-musl": "15.5.2", "@next/swc-linux-x64-gnu": "15.5.2", "@next/swc-linux-x64-musl": "15.5.2", "@next/swc-win32-arm64-msvc": "15.5.2", "@next/swc-win32-x64-msvc": "15.5.2", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": "dist/bin/next" }, "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q=="],
|
"next": ["next@15.5.14", "", { "dependencies": { "@next/env": "15.5.14", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.14", "@next/swc-darwin-x64": "15.5.14", "@next/swc-linux-arm64-gnu": "15.5.14", "@next/swc-linux-arm64-musl": "15.5.14", "@next/swc-linux-x64-gnu": "15.5.14", "@next/swc-linux-x64-musl": "15.5.14", "@next/swc-win32-arm64-msvc": "15.5.14", "@next/swc-win32-x64-msvc": "15.5.14", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ=="],
|
||||||
|
|
||||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
@@ -843,8 +843,6 @@
|
|||||||
|
|
||||||
"eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
|
"eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
|
||||||
|
|
||||||
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
|
||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": "bin/semver.js" }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": "bin/semver.js" }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.7.0",
|
"@supabase/ssr": "^0.7.0",
|
||||||
"@supabase/supabase-js": "^2.57.4",
|
"@supabase/supabase-js": "^2.57.4",
|
||||||
"next": "15.5.2",
|
"next": "15.5.14",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0"
|
||||||
},
|
},
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.2",
|
"eslint-config-next": "15.5.14",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Document, Submission, InsightsResult } from '@/libs/api';
|
import type { Document, Submission, InsightsResult } from '@/libs/api';
|
||||||
|
|
||||||
const NOW = new Date().toISOString();
|
|
||||||
const D = (daysAgo: number) => new Date(Date.now() - daysAgo * 86_400_000).toISOString();
|
const D = (daysAgo: number) => new Date(Date.now() - daysAgo * 86_400_000).toISOString();
|
||||||
|
|
||||||
const ROOT_VERSION_ID = 'demo-v1';
|
const ROOT_VERSION_ID = 'demo-v1';
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export type PublicAsset = {
|
|||||||
version_id?: string | null;
|
version_id?: string | null;
|
||||||
submission_id?: string | null;
|
submission_id?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
paperless_share_url?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PublicAssetAnalytics = {
|
export type PublicAssetAnalytics = {
|
||||||
@@ -229,11 +230,23 @@ export async function publishVersion(
|
|||||||
versionId?: string | null,
|
versionId?: string | null,
|
||||||
submissionId?: string | null,
|
submissionId?: string | null,
|
||||||
slug?: string | null,
|
slug?: string | null,
|
||||||
|
expiresAt?: string | null,
|
||||||
): Promise<PublicAsset> {
|
): Promise<PublicAsset> {
|
||||||
return req<PublicAsset>('/api/v1/public/publish', {
|
return req<PublicAsset>('/api/v1/public/publish', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }),
|
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null, expires_at: expiresAt ?? null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createShareLink(
|
||||||
|
slug: string,
|
||||||
|
expirationDate?: string | null,
|
||||||
|
): Promise<PublicAsset> {
|
||||||
|
return req<PublicAsset>(`/api/v1/public/${encodeURIComponent(slug)}/share-links`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ expiration_date: expirationDate ?? null }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ async function verifySession(token: string): Promise<boolean> {
|
|||||||
|
|
||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
if (!req.nextUrl.pathname.startsWith('/dashboard')) return NextResponse.next();
|
if (!req.nextUrl.pathname.startsWith('/dashboard')) return NextResponse.next();
|
||||||
|
if (process.env.NEXT_PUBLIC_DEMO === 'true') return NextResponse.next();
|
||||||
const session = req.cookies.get('session')?.value;
|
const session = req.cookies.get('session')?.value;
|
||||||
const oidc = req.cookies.get('oidc_token')?.value;
|
const oidc = req.cookies.get('oidc_token')?.value;
|
||||||
if (oidc) return NextResponse.next();
|
if (oidc) return NextResponse.next();
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export interface PublicAsset {
|
|||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
|
url?: string | null;
|
||||||
|
paperlessShareUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AISuggestion {
|
export interface AISuggestion {
|
||||||
|
|||||||
@@ -13,14 +13,9 @@ def validate_patchset(
|
|||||||
document: StructuredDocument,
|
document: StructuredDocument,
|
||||||
patches: Iterable[PatchPayload],
|
patches: Iterable[PatchPayload],
|
||||||
*,
|
*,
|
||||||
max_changes: int = 12,
|
|
||||||
max_growth_ratio: float = 1.45,
|
max_growth_ratio: float = 1.45,
|
||||||
) -> None:
|
) -> None:
|
||||||
patch_list = list(patches)
|
patch_list = list(patches)
|
||||||
if len(patch_list) > max_changes:
|
|
||||||
raise PatchValidationError(
|
|
||||||
f"Patchset exceeds max changes ({len(patch_list)} > {max_changes})"
|
|
||||||
)
|
|
||||||
block_map = {block.path: block for block in document.blocks}
|
block_map = {block.path: block for block in document.blocks}
|
||||||
for patch in patch_list:
|
for patch in patch_list:
|
||||||
block = block_map.get(patch.target_path)
|
block = block_map.get(patch.target_path)
|
||||||
|
|||||||
0
dlib/integrations/__init__.py
Normal file
0
dlib/integrations/__init__.py
Normal file
88
dlib/integrations/paperless.py
Normal file
88
dlib/integrations/paperless.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class PaperlessClient:
|
||||||
|
def __init__(self, base_url: str, token: str) -> None:
|
||||||
|
self._base = base_url.rstrip("/")
|
||||||
|
self._headers = {"Authorization": f"Token {token}"}
|
||||||
|
|
||||||
|
def _get(self, path: str, **params) -> dict:
|
||||||
|
r = httpx.get(f"{self._base}{path}", headers=self._headers, params=params, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def _post(self, path: str, **kwargs) -> dict:
|
||||||
|
r = httpx.post(f"{self._base}{path}", headers=self._headers, timeout=30, **kwargs)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def _delete(self, path: str) -> None:
|
||||||
|
r = httpx.delete(f"{self._base}{path}", headers=self._headers, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
def upload_document(self, pdf_bytes: bytes, title: str, tags: list[int] | None = None) -> int:
|
||||||
|
"""Upload PDF to paperless and return the created document_id (polls until task completes)."""
|
||||||
|
files = {"document": (f"{title}.pdf", pdf_bytes, "application/pdf")}
|
||||||
|
data: dict = {"title": title}
|
||||||
|
if tags:
|
||||||
|
data["tags"] = tags
|
||||||
|
resp = self._post("/api/documents/post_document/", files=files, data=data)
|
||||||
|
task_id = resp if isinstance(resp, str) else resp.get("task_id", resp)
|
||||||
|
return self._poll_task(str(task_id))
|
||||||
|
|
||||||
|
def _poll_task(self, task_id: str, max_wait: int = 60) -> int:
|
||||||
|
delay = 2
|
||||||
|
elapsed = 0
|
||||||
|
while elapsed < max_wait:
|
||||||
|
time.sleep(delay)
|
||||||
|
elapsed += delay
|
||||||
|
result = self._get("/api/tasks/", task_id=task_id)
|
||||||
|
tasks = result if isinstance(result, list) else result.get("results", [])
|
||||||
|
if not tasks:
|
||||||
|
delay = min(delay * 2, 10)
|
||||||
|
continue
|
||||||
|
task = tasks[0]
|
||||||
|
if task.get("status") == "SUCCESS":
|
||||||
|
return int(task["related_document"])
|
||||||
|
if task.get("status") in ("FAILURE", "REVOKED"):
|
||||||
|
raise RuntimeError(f"Paperless task {task_id} failed: {task.get('result')}")
|
||||||
|
delay = min(delay * 2, 10)
|
||||||
|
raise TimeoutError(f"Paperless task {task_id} did not complete within {max_wait}s")
|
||||||
|
|
||||||
|
def create_share_link(
|
||||||
|
self, document_id: int, expiration: datetime | None = None
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Create a share link for document_id. Returns (share_link_id, full share URL)."""
|
||||||
|
payload: dict = {"document": document_id}
|
||||||
|
if expiration:
|
||||||
|
payload["expiration_date"] = expiration.isoformat()
|
||||||
|
resp = self._post("/api/share_links/", json=payload)
|
||||||
|
slug = resp["slug"]
|
||||||
|
link_id = int(resp["id"])
|
||||||
|
return link_id, f"{self._base}/share/{slug}"
|
||||||
|
|
||||||
|
def get_share_links(self, document_id: int) -> list[dict]:
|
||||||
|
return self._get(f"/api/documents/{document_id}/share_links/").get("results", [])
|
||||||
|
|
||||||
|
def delete_share_link(self, share_link_id: int) -> None:
|
||||||
|
self._delete(f"/api/share_links/{share_link_id}/")
|
||||||
|
|
||||||
|
def delete_document(self, document_id: int) -> None:
|
||||||
|
self._delete(f"/api/documents/{document_id}/")
|
||||||
|
|
||||||
|
|
||||||
|
def get_paperless_client(settings: "Settings") -> PaperlessClient | None:
|
||||||
|
if not settings.paperless_enabled:
|
||||||
|
return None
|
||||||
|
if not settings.paperless_base_url or not settings.paperless_token:
|
||||||
|
return None
|
||||||
|
return PaperlessClient(settings.paperless_base_url, settings.paperless_token)
|
||||||
Reference in New Issue
Block a user