Merge remote-tracking branch 'origin/main' into fix/s3-cleanup-on-delete
# Conflicts: # server/pkg/db/generated/models.go
This commit is contained in:
commit
114d2a3acf
17 changed files with 426 additions and 12 deletions
1
.eslintcache
Normal file
1
.eslintcache
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -6,10 +6,11 @@ import {
|
|||
useImperativeHandle,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { Bot, Hash } from "lucide-react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -19,7 +20,9 @@ import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
|
|||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "member" | "agent";
|
||||
type: "member" | "agent" | "issue";
|
||||
/** Secondary text shown below the label (e.g. issue title) */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MentionListProps {
|
||||
|
|
@ -88,6 +91,10 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
|||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="h-3 w-3" />
|
||||
</span>
|
||||
) : item.type === "issue" ? (
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Hash className="h-3 w-3" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-[9px] font-medium">
|
||||
{item.label
|
||||
|
|
@ -98,7 +105,12 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
|||
.slice(0, 2)}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{item.label}</span>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -117,6 +129,7 @@ export function createMentionSuggestion(): Omit<
|
|||
return {
|
||||
items: ({ query }) => {
|
||||
const { members, agents } = useWorkspaceStore.getState();
|
||||
const { issues } = useIssueStore.getState();
|
||||
const q = query.toLowerCase();
|
||||
|
||||
const memberItems: MentionItem[] = members
|
||||
|
|
@ -131,7 +144,20 @@ export function createMentionSuggestion(): Omit<
|
|||
.filter((a) => a.name.toLowerCase().includes(q))
|
||||
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
||||
|
||||
return [...memberItems, ...agentItems].slice(0, 10);
|
||||
const issueItems: MentionItem[] = issues
|
||||
.filter(
|
||||
(i) =>
|
||||
i.identifier.toLowerCase().includes(q) ||
|
||||
i.title.toLowerCase().includes(q),
|
||||
)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
label: i.identifier,
|
||||
type: "issue" as const,
|
||||
description: i.title,
|
||||
}));
|
||||
|
||||
return [...memberItems, ...agentItems, ...issueItems].slice(0, 10);
|
||||
},
|
||||
|
||||
render: () => {
|
||||
|
|
|
|||
|
|
@ -223,6 +223,45 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
|
|||
const onSubmitRef = useRef(onSubmit);
|
||||
const onUploadFileRef = useRef(onUploadFile);
|
||||
|
||||
// Helper to get markdown from @tiptap/markdown extension.
|
||||
// Post-processes mention shortcodes [@ id="..." label="..."] → markdown
|
||||
// links, using the Tiptap JSON doc for type info, in case the
|
||||
// renderMarkdown override doesn't take effect.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getEditorMarkdown = (ed: any): string => {
|
||||
const md: string = ed?.getMarkdown?.() ?? "";
|
||||
if (!md || !md.includes("[@ ")) return md;
|
||||
|
||||
// Build type map from editor JSON (which always has the type attr)
|
||||
const json = ed?.getJSON?.();
|
||||
const typeMap = new Map<string, string>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function walk(node: any) {
|
||||
if (node?.type === "mention" && node.attrs?.id) {
|
||||
typeMap.set(node.attrs.id, node.attrs.type || "member");
|
||||
}
|
||||
if (node?.content) node.content.forEach(walk);
|
||||
}
|
||||
if (json) walk(json);
|
||||
|
||||
return md.replace(
|
||||
/\[@\s+([^\]]*)\]/g,
|
||||
(match: string, attrString: string) => {
|
||||
const attrs: Record<string, string> = {};
|
||||
const re = /(\w+)="([^"]*)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(attrString)) !== null) {
|
||||
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
|
||||
}
|
||||
const { id, label } = attrs;
|
||||
if (!id || !label) return match;
|
||||
const type = typeMap.get(id) || "member";
|
||||
const display = type === "issue" ? label : `@${label}`;
|
||||
return `[${display}](mention://${type}/${id})`;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Keep refs in sync without recreating editor
|
||||
onUpdateRef.current = onUpdate;
|
||||
onSubmitRef.current = onSubmit;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import * as React from 'react'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CodeBlock, InlineCode } from './CodeBlock'
|
||||
import { preprocessLinks } from './linkify'
|
||||
import { IssueMentionCard } from '@/features/issues/components/issue-mention-card'
|
||||
|
||||
/**
|
||||
* Render modes for markdown content:
|
||||
|
|
@ -43,6 +44,37 @@ export interface MarkdownProps {
|
|||
onFileClick?: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom URL transform that allows mention:// protocol (used for @mentions)
|
||||
* while keeping the default security for all other URLs.
|
||||
*/
|
||||
function urlTransform(url: string): string {
|
||||
if (url.startsWith('mention://')) return url
|
||||
return defaultUrlTransform(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to markdown
|
||||
* link format [@LABEL](mention://member/UUID) so they render as styled mentions.
|
||||
*/
|
||||
function preprocessMentionShortcodes(text: string): string {
|
||||
if (!text.includes('[@ ')) return text
|
||||
return text.replace(
|
||||
/\[@\s+([^\]]*)\]/g,
|
||||
(match, attrString: string) => {
|
||||
const attrs: Record<string, string> = {}
|
||||
const re = /(\w+)="([^"]*)"/g
|
||||
let m
|
||||
while ((m = re.exec(attrString)) !== null) {
|
||||
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2]
|
||||
}
|
||||
const { id, label } = attrs
|
||||
if (!id || !label) return match
|
||||
return `[@${label}](mention://member/${id})`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// File path detection regex - matches paths starting with /, ~/, or ./
|
||||
const FILE_PATH_REGEX =
|
||||
/^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i
|
||||
|
|
@ -67,8 +99,13 @@ function createComponents(
|
|||
),
|
||||
// Links: Make clickable with callbacks, or render as mention
|
||||
a: ({ href, children }) => {
|
||||
// Mention links: mention://member/id or mention://agent/id
|
||||
// Mention links: mention://member/id, mention://agent/id, mention://issue/id
|
||||
if (href?.startsWith('mention://')) {
|
||||
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue)\/(.+)$/)
|
||||
if (mentionMatch?.[1] === 'issue' && mentionMatch[2]) {
|
||||
const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined
|
||||
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />
|
||||
}
|
||||
return (
|
||||
<span className="text-primary font-semibold mx-0.5">
|
||||
{children}
|
||||
|
|
@ -282,14 +319,18 @@ export function Markdown({
|
|||
[mode, onUrlClick, onFileClick]
|
||||
)
|
||||
|
||||
// Preprocess to convert raw URLs and file paths to markdown links
|
||||
const processedContent = React.useMemo(() => preprocessLinks(children), [children])
|
||||
// Preprocess: convert mention shortcodes and raw URLs/file paths to markdown links
|
||||
const processedContent = React.useMemo(
|
||||
() => preprocessLinks(preprocessMentionShortcodes(children)),
|
||||
[children]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('markdown-content break-words', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
urlTransform={urlTransform}
|
||||
components={components}
|
||||
>
|
||||
{processedContent}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { cn } from "@/lib/utils";
|
|||
import { useActorName } from "@/features/workspace";
|
||||
import { timeAgo } from "@/shared/utils";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { ReplyInput } from "./reply-input";
|
||||
import type { TimelineEntry } from "@/shared/types";
|
||||
|
||||
|
|
@ -164,7 +165,7 @@ function CommentRow({
|
|||
) : (
|
||||
<>
|
||||
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
|
||||
<RichTextEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
|
||||
</div>
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
|
|
@ -339,7 +340,7 @@ function CommentCard({
|
|||
) : (
|
||||
<>
|
||||
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
|
||||
<RichTextEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
|
||||
</div>
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export { IssuesPage } from "./issues-page";
|
|||
export { CommentCard } from "./comment-card";
|
||||
export { CommentInput } from "./comment-input";
|
||||
export { ReplyInput } from "./reply-input";
|
||||
export { IssueMentionCard } from "./issue-mention-card";
|
||||
|
|
|
|||
37
apps/web/features/issues/components/issue-mention-card.tsx
Normal file
37
apps/web/features/issues/components/issue-mention-card.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
|
||||
interface IssueMentionCardProps {
|
||||
issueId: string;
|
||||
/** Fallback text when issue is not in store (e.g. "MUL-7") */
|
||||
fallbackLabel?: string;
|
||||
}
|
||||
|
||||
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="text-primary font-medium cursor-pointer hover:underline"
|
||||
>
|
||||
{fallbackLabel ?? issueId.slice(0, 8)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer no-underline"
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
|
||||
<span className="text-foreground">{issue.title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,7 +16,11 @@ import { useWorkspaceStore } from "@/features/workspace";
|
|||
import { createLogger } from "@/shared/logger";
|
||||
import { useRealtimeSync } from "./use-realtime-sync";
|
||||
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080/ws";
|
||||
const WS_URL =
|
||||
process.env.NEXT_PUBLIC_WS_URL ||
|
||||
(typeof window !== "undefined"
|
||||
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`
|
||||
: "ws://localhost:8080/ws");
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const remoteApiUrl = process.env.REMOTE_API_URL ?? "https://multica-api.copilothub.ai";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${remoteApiUrl}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/ws",
|
||||
destination: `${remoteApiUrl}/ws`,
|
||||
},
|
||||
{
|
||||
source: "/auth/:path*",
|
||||
destination: `${remoteApiUrl}/auth/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export { ApiClient } from "./client";
|
|||
export type { LoginResponse } from "./client";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL, { logger: createLogger("api") });
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,15 @@ func GeneratePATToken() (string, error) {
|
|||
return "mul_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// GenerateDaemonToken creates a new daemon auth token: "mdt_" + 40 random hex chars.
|
||||
func GenerateDaemonToken() (string, error) {
|
||||
b := make([]byte, 20) // 20 bytes = 40 hex chars
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate daemon token: %w", err)
|
||||
}
|
||||
return "mdt_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// HashToken returns the hex-encoded SHA-256 hash of a token string.
|
||||
func HashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
|
|
|
|||
112
server/internal/middleware/daemon_auth.go
Normal file
112
server/internal/middleware/daemon_auth.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// Daemon context keys.
|
||||
type daemonContextKey int
|
||||
|
||||
const (
|
||||
ctxKeyDaemonWorkspaceID daemonContextKey = iota
|
||||
ctxKeyDaemonID
|
||||
)
|
||||
|
||||
// DaemonWorkspaceIDFromContext returns the workspace ID set by DaemonAuth middleware.
|
||||
func DaemonWorkspaceIDFromContext(ctx context.Context) string {
|
||||
id, _ := ctx.Value(ctxKeyDaemonWorkspaceID).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// DaemonIDFromContext returns the daemon ID set by DaemonAuth middleware.
|
||||
func DaemonIDFromContext(ctx context.Context) string {
|
||||
id, _ := ctx.Value(ctxKeyDaemonID).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// DaemonAuth validates daemon auth tokens (mdt_ prefix) or falls back to
|
||||
// JWT/PAT validation for backward compatibility with daemons that
|
||||
// authenticate via user tokens.
|
||||
func DaemonAuth(queries *db.Queries) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
slog.Debug("daemon_auth: missing authorization header", "path", r.URL.Path)
|
||||
writeError(w, http.StatusUnauthorized, "missing authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
slog.Debug("daemon_auth: invalid format", "path", r.URL.Path)
|
||||
writeError(w, http.StatusUnauthorized, "invalid authorization format")
|
||||
return
|
||||
}
|
||||
|
||||
// Daemon token: "mdt_" prefix.
|
||||
if strings.HasPrefix(tokenString, "mdt_") {
|
||||
hash := auth.HashToken(tokenString)
|
||||
dt, err := queries.GetDaemonTokenByHash(r.Context(), hash)
|
||||
if err != nil {
|
||||
slog.Warn("daemon_auth: invalid daemon token", "path", r.URL.Path, "error", err)
|
||||
writeError(w, http.StatusUnauthorized, "invalid daemon token")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxKeyDaemonWorkspaceID, uuidToString(dt.WorkspaceID))
|
||||
ctx = context.WithValue(ctx, ctxKeyDaemonID, dt.DaemonID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: PAT tokens ("mul_" prefix).
|
||||
if strings.HasPrefix(tokenString, "mul_") {
|
||||
hash := auth.HashToken(tokenString)
|
||||
pat, err := queries.GetPersonalAccessTokenByHash(r.Context(), hash)
|
||||
if err != nil {
|
||||
slog.Warn("daemon_auth: invalid PAT", "path", r.URL.Path, "error", err)
|
||||
writeError(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
r.Header.Set("X-User-ID", uuidToString(pat.UserID))
|
||||
go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: JWT tokens.
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return auth.JWTSecret(), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
slog.Warn("daemon_auth: invalid token", "path", r.URL.Path, "error", err)
|
||||
writeError(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "invalid claims")
|
||||
return
|
||||
}
|
||||
sub, ok := claims["sub"].(string)
|
||||
if !ok || strings.TrimSpace(sub) == "" {
|
||||
writeError(w, http.StatusUnauthorized, "invalid claims")
|
||||
return
|
||||
}
|
||||
r.Header.Set("X-User-ID", sub)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
1
server/migrations/029_daemon_token.down.sql
Normal file
1
server/migrations/029_daemon_token.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS daemon_token;
|
||||
11
server/migrations/029_daemon_token.up.sql
Normal file
11
server/migrations/029_daemon_token.up.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE daemon_token (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token_hash TEXT NOT NULL,
|
||||
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
daemon_id TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_daemon_token_hash ON daemon_token(token_hash);
|
||||
CREATE INDEX idx_daemon_token_workspace_daemon ON daemon_token(workspace_id, daemon_id);
|
||||
88
server/pkg/db/generated/daemon_token.sql.go
Normal file
88
server/pkg/db/generated/daemon_token.sql.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: daemon_token.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createDaemonToken = `-- name: CreateDaemonToken :one
|
||||
INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, token_hash, workspace_id, daemon_id, expires_at, created_at
|
||||
`
|
||||
|
||||
type CreateDaemonTokenParams struct {
|
||||
TokenHash string `json:"token_hash"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateDaemonToken(ctx context.Context, arg CreateDaemonTokenParams) (DaemonToken, error) {
|
||||
row := q.db.QueryRow(ctx, createDaemonToken,
|
||||
arg.TokenHash,
|
||||
arg.WorkspaceID,
|
||||
arg.DaemonID,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
var i DaemonToken
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TokenHash,
|
||||
&i.WorkspaceID,
|
||||
&i.DaemonID,
|
||||
&i.ExpiresAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteDaemonTokensByWorkspaceAndDaemon = `-- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec
|
||||
DELETE FROM daemon_token
|
||||
WHERE workspace_id = $1 AND daemon_id = $2
|
||||
`
|
||||
|
||||
type DeleteDaemonTokensByWorkspaceAndDaemonParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteDaemonTokensByWorkspaceAndDaemon(ctx context.Context, arg DeleteDaemonTokensByWorkspaceAndDaemonParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteDaemonTokensByWorkspaceAndDaemon, arg.WorkspaceID, arg.DaemonID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteExpiredDaemonTokens = `-- name: DeleteExpiredDaemonTokens :exec
|
||||
DELETE FROM daemon_token
|
||||
WHERE expires_at <= now()
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredDaemonTokens(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, deleteExpiredDaemonTokens)
|
||||
return err
|
||||
}
|
||||
|
||||
const getDaemonTokenByHash = `-- name: GetDaemonTokenByHash :one
|
||||
SELECT id, token_hash, workspace_id, daemon_id, expires_at, created_at FROM daemon_token
|
||||
WHERE token_hash = $1 AND expires_at > now()
|
||||
`
|
||||
|
||||
func (q *Queries) GetDaemonTokenByHash(ctx context.Context, tokenHash string) (DaemonToken, error) {
|
||||
row := q.db.QueryRow(ctx, getDaemonTokenByHash, tokenHash)
|
||||
var i DaemonToken
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TokenHash,
|
||||
&i.WorkspaceID,
|
||||
&i.DaemonID,
|
||||
&i.ExpiresAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -127,6 +127,15 @@ type DaemonConnection struct {
|
|||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DaemonToken struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
TokenHash string `json:"token_hash"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type InboxItem struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
|
|
|
|||
16
server/pkg/db/queries/daemon_token.sql
Normal file
16
server/pkg/db/queries/daemon_token.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- name: CreateDaemonToken :one
|
||||
INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetDaemonTokenByHash :one
|
||||
SELECT * FROM daemon_token
|
||||
WHERE token_hash = $1 AND expires_at > now();
|
||||
|
||||
-- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec
|
||||
DELETE FROM daemon_token
|
||||
WHERE workspace_id = $1 AND daemon_id = $2;
|
||||
|
||||
-- name: DeleteExpiredDaemonTokens :exec
|
||||
DELETE FROM daemon_token
|
||||
WHERE expires_at <= now();
|
||||
Loading…
Add table
Add a link
Reference in a new issue