Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ service cloud.firestore {
&& (!('roast' in data) || (data.roast is string && data.roast.size() <= 8000))
&& (!('roastBrutal' in data) || (data.roastBrutal is string && data.roastBrutal.size() <= 8000))
&& (!('roastMild' in data) || (data.roastMild is string && data.roastMild.size() <= 8000))
// extraTagsByCategory: mapa de categoria → lista de nomes de skills criadas pelo usuário
&& (!('extraTagsByCategory' in data) || data.extraTagsByCategory is map)
&& data.skills is map
&& data.skills.keys().hasAll(['frontend', 'backend', 'ux_ui', 'dados', 'hardware_android', 'vibe_coding'])
&& data.skills.frontend is number && data.skills.backend is number
Expand All @@ -71,6 +73,37 @@ service cloud.firestore {
&& data.canvas.veto is list && data.canvas.veto.size() <= 50;
}

// ─────────────────────────────────────────────
// SKILLS
// Catálogo global de skills. Qualquer usuário
// autenticado pode ler. Criação permitida apenas
// para skills com campos obrigatórios válidos.
// Edição e deleção bloqueadas para usuários comuns.
// ─────────────────────────────────────────────
function isValidSkill(data) {
return data.keys().hasAll(['id', 'name', 'normalizedName', 'status', 'usageCount', 'createdBy', 'createdAt'])
&& data.id is string && data.id.size() > 0 && data.id.size() <= 128
&& data.name is string && data.name.size() > 0 && data.name.size() <= 100
&& data.normalizedName is string && data.normalizedName.size() > 0 && data.normalizedName.size() <= 100
&& data.status is string && data.status in ['pending', 'approved', 'rejected']
&& data.usageCount is number && data.usageCount >= 0
&& data.createdBy is string
&& (!('category' in data) || data.category == null || (data.category is string && data.category.size() <= 50));
}

match /skills/{skillId} {
// Leitura pública para usuários autenticados (autocomplete)
allow read: if isSignedIn();

// Criação: usuário autenticado, skill válida, ID determinístico = normalizedName
allow create: if isSignedIn()
&& skillId == incoming().normalizedName
&& isValidSkill(incoming());

// Atualização e deleção: bloqueadas para usuários comuns (apenas admin via console)
allow update, delete: if false;
}

match /test/connection {
allow read: if true;
}
Expand Down Expand Up @@ -110,15 +143,15 @@ service cloud.firestore {
&& profileId == request.auth.uid
&& isValidProfile(incoming())
&& incoming().createdAt == request.time
&& incoming().keys().hasOnly(['userId', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'skills', 'canvas', 'status', 'squadId', 'eventId', 'roast', 'roastBrutal', 'roastMild', 'createdAt', 'updatedAt', 'visibility']);
&& incoming().keys().hasOnly(['userId', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'skills', 'canvas', 'status', 'squadId', 'eventId', 'roast', 'roastBrutal', 'roastMild', 'createdAt', 'updatedAt', 'visibility', 'extraTagsByCategory']);

allow update: if isSignedIn()
&& isValidId(profileId)
&& isValidProfile(incoming())
&& incoming().userId == existing().userId
&& incoming().createdAt == existing().createdAt
&& (
(incoming().diff(existing()).affectedKeys().hasOnly(['skills', 'canvas', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'status', 'squadId', 'eventId', 'updatedAt', 'roast', 'roastBrutal', 'roastMild', 'visibility']))
(incoming().diff(existing()).affectedKeys().hasOnly(['skills', 'canvas', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'status', 'squadId', 'eventId', 'updatedAt', 'roast', 'roastBrutal', 'roastMild', 'visibility', 'extraTagsByCategory']))
||
false
);
Expand Down Expand Up @@ -171,7 +204,6 @@ service cloud.firestore {
&& isValidMessage(incoming())
&& incoming().createdAt == request.time;

// Allow listing for conversation thread queries (by conversationId, senderId or receiverId)
allow list: if isSignedIn()
&& (
resource.data.receiverId == request.auth.uid
Expand Down
11 changes: 11 additions & 0 deletions src/application/factories/skillServiceFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { SkillService } from "@/application/services/SkillService";
import { FirebaseSkillRepository } from "@/infrastructure/firebase/skillRepository";

/**
* Factory simples para evitar acoplamento direto no frontend
* e manter consistência na criação do service.
*/
export function makeSkillService() {
const repository = new FirebaseSkillRepository();
return new SkillService(repository);
}
42 changes: 42 additions & 0 deletions src/application/services/SkillService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Timestamp } from "firebase/firestore";

import type { Skill } from "@/domain/entities/Skill";
import type { ISkillRepository } from "@/domain/ports/ISkillRepository";

const normalizeSkillName = (name: string) => name.trim().toLowerCase().replace(/\s+/g, " ");

export class SkillService {
constructor(private readonly repository: ISkillRepository) {}

async createOrGetSkill(rawName: string, category: string | null = null): Promise<Skill> {
if (!rawName || rawName.trim().length === 0) {
throw new Error("Skill inválida");
}

const normalizedName = normalizeSkillName(rawName);

const existing = await this.repository.getSkillById(normalizedName);
if (existing) return existing;

const newSkill: Skill = {
id: normalizedName,
name: rawName.trim(),
normalizedName,
category,
status: "pending",
usageCount: 1,
createdBy: "",
createdAt: Timestamp.now(),
};

try {
await this.repository.createSkill(newSkill);
} catch {
const fallback = await this.repository.getSkillById(normalizedName);
if (fallback) return fallback;
throw new Error(`Erro ao criar skill: ${rawName}`);
}

return newSkill;
}
}
14 changes: 14 additions & 0 deletions src/domain/entities/Skill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Timestamp } from "firebase/firestore";

export type SkillStatus = "pending" | "approved" | "rejected";

export interface Skill {
id: string;
name: string;
normalizedName: string;
category: string | null;
status: SkillStatus;
usageCount: number;
createdBy: string;
createdAt: Timestamp;
}
11 changes: 11 additions & 0 deletions src/domain/ports/ISkillRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Skill } from "@/domain/entities/Skill";

export interface ISkillRepository {
getAllSkills(): Promise<Skill[]>;

getSkillById(id: string): Promise<Skill | null>;

createSkill(skill: Skill): Promise<void>;

updateSkill(id: string, data: Partial<Skill>): Promise<void>;
}
230 changes: 230 additions & 0 deletions src/features/onboarding/components/SkillAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { Search, Plus, X } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { useRef, useState, useEffect, useCallback } from "react";

import type { Skill } from "@/domain/entities/Skill";

interface Props {
search: string;
filteredSkills: Skill[];
selectedSkills: Skill[];
onSearchChange: (value: string) => void;
onSelectSkill: (skill: Skill) => void;
onRemoveSkill: (skillId: string) => void;
onCreateSkill: (name: string) => Promise<void>;
}

export function SkillAutocomplete({
search,
filteredSkills,
selectedSkills,
onSearchChange,
onSelectSkill,
onRemoveSkill,
onCreateSkill,
}: Props) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);

const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);

const trimmed = search.trim();

// Fecha o dropdown ao clicar fora do container inteiro
// Isso é mais confiável do que onBlur + setTimeout
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);

// Mostra resultados sempre que está aberto — lista completa quando vazio, filtrada quando tem texto
const showNoResults = open && trimmed.length > 0 && filteredSkills.length === 0;
const showResults = open && filteredSkills.length > 0;

const handleSelect = useCallback(
(skill: Skill) => {
onSelectSkill(skill);
onSearchChange("");
setOpen(false);
inputRef.current?.focus();
},
[onSelectSkill, onSearchChange],
);

const handleCreate = useCallback(async () => {
if (!trimmed || creating) return;
setCreating(true);
try {
await onCreateSkill(trimmed);
onSearchChange("");
setOpen(false);
} finally {
setCreating(false);
}
}, [trimmed, creating, onCreateSkill, onSearchChange]);

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
setOpen(false);
onSearchChange("");
return;
}
if (e.key === "Enter") {
e.preventDefault();
if (showNoResults) {
handleCreate();
} else if (filteredSkills.length === 1) {
handleSelect(filteredSkills[0]);
}
}
};

return (
<div ref={containerRef} className="space-y-4">
{/* ── Search input ── */}
<div className="relative">
<div
className={`flex items-center neo-border border-4 bg-white transition-all
${open ? "shadow-[10px_10px_0_0_#000]" : "shadow-[6px_6px_0_0_#000] hover:shadow-[8px_8px_0_0_#000]"}`}
>
<Search className="w-5 h-5 ml-4 text-neo-black/40 shrink-0" />

<input
ref={inputRef}
type="text"
value={search}
placeholder="BUSCAR OU ADICIONAR SKILL..."
autoComplete="off"
spellCheck={false}
onChange={(e) => {
onSearchChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
className="flex-1 px-4 py-3 font-black text-sm uppercase tracking-widest bg-transparent outline-none text-neo-black placeholder:text-neo-black/25"
/>

{search && (
<button
type="button"
onClick={() => {
onSearchChange("");
inputRef.current?.focus();
setOpen(true);
}}
className="mr-3 p-1 hover:bg-neo-pink/20 transition-colors"
tabIndex={-1}
>
<X className="w-4 h-4 text-neo-black/50" />
</button>
)}
</div>

{/* ── Dropdown ── */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.1 }}
className="absolute z-50 top-full left-0 right-0 mt-1 neo-border border-4 bg-white shadow-[8px_8px_0_0_#000] max-h-60 overflow-y-auto"
>
{showResults && (
<>
{/* Label de contexto */}
<div className="px-4 pt-3 pb-1">
<span className="text-[10px] font-black uppercase tracking-widest text-neo-black/30">
{trimmed ? `Resultados para "${trimmed}"` : "Todas as skills disponíveis"}
</span>
</div>

{filteredSkills.map((skill) => (
<button
key={skill.id}
type="button"
// mousedown registra antes do blur — sem precisar de setTimeout
onMouseDown={(e) => {
e.preventDefault(); // impede blur no input
handleSelect(skill);
}}
className="w-full text-left px-4 py-3 font-black text-xs uppercase tracking-widest hover:bg-neo-cyan/20 border-b-2 border-neo-black/10 last:border-0 transition-colors"
>
{skill.name}
</button>
))}
</>
)}

{showNoResults && (
<div className="p-4 space-y-3">
<p className="text-[11px] font-black uppercase tracking-widest text-neo-black/50">
Nenhuma skill encontrada.
</p>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
handleCreate();
}}
disabled={creating}
className="flex items-center gap-2 bg-neo-black text-white px-4 py-2 neo-border border-2 font-black text-xs uppercase tracking-widest hover:bg-neo-black/80 disabled:opacity-50 transition-colors w-full"
>
<Plus className="w-4 h-4 shrink-0" />
{creating ? "ADICIONANDO..." : `Adicionar "${trimmed}"`}
</button>
</div>
)}

{/* Campo vazio + dropdown aberto: nenhum texto digitado ainda */}
{open && !trimmed && filteredSkills.length === 0 && (
<div className="px-4 py-3">
<p className="text-[11px] font-black uppercase tracking-widest text-neo-black/40">
Todas as skills já foram adicionadas.
</p>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>

{/* ── Selected skills chips ── */}
{selectedSkills.length > 0 && (
<div className="flex flex-wrap gap-2">
<AnimatePresence>
{selectedSkills.map((skill) => (
<motion.div
key={skill.id}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{ duration: 0.12 }}
className="flex items-center gap-2 bg-neo-black text-white px-3 py-1.5 neo-border border-2 shadow-[3px_3px_0_0_#00e5ff]"
>
<span className="font-black text-xs uppercase tracking-widest text-neo-cyan">
{skill.name}
</span>
<button
type="button"
onClick={() => onRemoveSkill(skill.id)}
className="text-white/40 hover:text-neo-pink transition-colors"
title={`Remover ${skill.name}`}
>
<X className="w-3.5 h-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</div>
);
}
Loading
Loading