feat(org): adicionar visibilidade publica e privada com protecao em buscas e join requests

This commit is contained in:
Gabriel Menezes 2026-11-05 15:22:13 -04:00
parent e7703f34e7
commit 3dff934d61
26 changed files with 149 additions and 26 deletions

View file

@ -7,6 +7,7 @@ import net.beetechgroup.beetask.entities.Project;
public class Organization { public class Organization {
private Long id; private Long id;
private String name; private String name;
private boolean isPrivate;
private List<Project> projects; private List<Project> projects;
private List<UserOrganization> users; private List<UserOrganization> users;
@ -26,6 +27,14 @@ public class Organization {
this.name = name; this.name = name;
} }
public boolean isPrivate() {
return isPrivate;
}
public void setPrivate(boolean isPrivate) {
this.isPrivate = isPrivate;
}
public List<Project> getProjects() { public List<Project> getProjects() {
return projects; return projects;
} }

View file

@ -21,6 +21,8 @@ public class OrganizationEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
private String name; private String name;
@Column(name = "is_private", nullable = false)
private boolean isPrivate;
@Column(name = "deleted_at") @Column(name = "deleted_at")
private java.time.LocalDateTime deletedAt; private java.time.LocalDateTime deletedAt;
@OneToMany(mappedBy = "organization") @OneToMany(mappedBy = "organization")
@ -50,6 +52,14 @@ public class OrganizationEntity {
this.name = name; this.name = name;
} }
public boolean isPrivate() {
return isPrivate;
}
public void setPrivate(boolean isPrivate) {
this.isPrivate = isPrivate;
}
public java.time.LocalDateTime getDeletedAt() { public java.time.LocalDateTime getDeletedAt() {
return deletedAt; return deletedAt;
} }

View file

@ -10,6 +10,7 @@ public class OrganizationEntityMapper {
OrganizationEntity entity = new OrganizationEntity(); OrganizationEntity entity = new OrganizationEntity();
entity.setId(organization.getId()); entity.setId(organization.getId());
entity.setName(organization.getName()); entity.setName(organization.getName());
entity.setPrivate(organization.isPrivate());
return entity; return entity;
} }
@ -19,6 +20,7 @@ public class OrganizationEntityMapper {
Organization organization = new Organization(); Organization organization = new Organization();
organization.setId(entity.getId()); organization.setId(entity.getId());
organization.setName(entity.getName()); organization.setName(entity.getName());
organization.setPrivate(entity.isPrivate());
return organization; return organization;
} }
} }

View file

@ -34,17 +34,17 @@ public class OrganizationRepositoryImpl implements OrganizationRepository, Panac
@Override @Override
public List<Organization> search(String query) { public List<Organization> search(String query) {
if (Objects.isNull(query) || query.isBlank()) { if (Objects.isNull(query) || query.isBlank()) {
List<Organization> organizations = findAll().list().stream() List<Organization> organizations = find("isPrivate = false").list().stream()
.map(OrganizationEntityMapper::toDomain) .map(OrganizationEntityMapper::toDomain)
.toList(); .toList();
LOGGER.infof("Loaded %d organizations with empty search query", organizations.size()); LOGGER.infof("Loaded %d public organizations with empty search query", organizations.size());
return organizations; return organizations;
} }
List<Organization> organizations = find("lower(name) like ?1", "%" + query.toLowerCase() + "%").stream() List<Organization> organizations = find("isPrivate = false and lower(name) like ?1", "%" + query.toLowerCase() + "%").stream()
.map(OrganizationEntityMapper::toDomain) .map(OrganizationEntityMapper::toDomain)
.toList(); .toList();
LOGGER.infof("Loaded %d organizations for search query '%s'", organizations.size(), query); LOGGER.infof("Loaded %d public organizations for search query '%s'", organizations.size(), query);
return organizations; return organizations;
} }

View file

@ -42,7 +42,7 @@ public class AuthControllerMapper {
output.expiresIn(), output.expiresIn(),
output.issuedAt(), output.issuedAt(),
output.organizations().stream() output.organizations().stream()
.map(org -> new LoginResponse.Organization(org.id(), org.name(), org.roles())) .map(org -> new LoginResponse.Organization(org.id(), org.name(), org.roles(), org.isPrivate()))
.collect(Collectors.toList()) .collect(Collectors.toList())
); );
} }

View file

@ -15,6 +15,7 @@ public record LoginResponse(
public record Organization( public record Organization(
Long id, Long id,
String name, String name,
List<String> roles List<String> roles,
boolean isPrivate
) {} ) {}
} }

View file

@ -1,5 +1,6 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.organization; package net.beetechgroup.beetask.interfaceadapters.controllers.organization;
public record CreateOrganizationRequest( public record CreateOrganizationRequest(
String name) { String name,
Boolean isPrivate) {
} }

View file

@ -1,4 +1,4 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.organization; package net.beetechgroup.beetask.interfaceadapters.controllers.organization;
public record CreateOrganizationResponse(Long id, String name) { public record CreateOrganizationResponse(Long id, String name, boolean isPrivate) {
} }

View file

@ -5,10 +5,11 @@ import net.beetechgroup.beetask.usecase.organization.create.CreateOrganizationOu
public class OrganizationControllerMapper { public class OrganizationControllerMapper {
public static CreateOrganizationInput toCreateOrganizationInput(CreateOrganizationRequest request, String userEmail) { public static CreateOrganizationInput toCreateOrganizationInput(CreateOrganizationRequest request, String userEmail) {
return new CreateOrganizationInput(request.name(), userEmail); boolean isPrivate = request.isPrivate() != null ? request.isPrivate() : false;
return new CreateOrganizationInput(request.name(), userEmail, isPrivate);
} }
public static CreateOrganizationResponse toCreateOrganizationResponse(CreateOrganizationOutput output) { public static CreateOrganizationResponse toCreateOrganizationResponse(CreateOrganizationOutput output) {
return new CreateOrganizationResponse(output.id(), output.name()); return new CreateOrganizationResponse(output.id(), output.name(), output.isPrivate());
} }
} }

View file

@ -15,6 +15,7 @@ public record LoginOutput(
public record OrganizationOutput( public record OrganizationOutput(
Long id, Long id,
String name, String name,
List<String> roles List<String> roles,
boolean isPrivate
) {} ) {}
} }

View file

@ -80,7 +80,8 @@ public class LoginUseCase {
return new LoginOutput.OrganizationOutput( return new LoginOutput.OrganizationOutput(
entry.getKey(), entry.getKey(),
first.getOrganization().getName(), first.getOrganization().getName(),
entry.getValue().stream().map(uo -> uo.getRole().name()).toList() entry.getValue().stream().map(uo -> uo.getRole().name()).toList(),
first.getOrganization().isPrivate()
); );
}) })
.toList(); .toList();

View file

@ -75,7 +75,8 @@ public class RefreshTokenUseCase {
return new LoginOutput.OrganizationOutput( return new LoginOutput.OrganizationOutput(
entry.getKey(), entry.getKey(),
first.getOrganization().getName(), first.getOrganization().getName(),
entry.getValue().stream().map(uo -> uo.getRole().name()).toList() entry.getValue().stream().map(uo -> uo.getRole().name()).toList(),
first.getOrganization().isPrivate()
); );
}) })
.toList(); .toList();

View file

@ -1,4 +1,4 @@
package net.beetechgroup.beetask.usecase.organization.create; package net.beetechgroup.beetask.usecase.organization.create;
public record CreateOrganizationInput(String name, String userEmail) { public record CreateOrganizationInput(String name, String userEmail, boolean isPrivate) {
} }

View file

@ -4,6 +4,6 @@ import net.beetechgroup.beetask.entities.organization.Organization;
public class CreateOrganizationMapper { public class CreateOrganizationMapper {
public static CreateOrganizationOutput toCreateOrganizationOutput(Organization organization) { public static CreateOrganizationOutput toCreateOrganizationOutput(Organization organization) {
return new CreateOrganizationOutput(organization.getId(), organization.getName()); return new CreateOrganizationOutput(organization.getId(), organization.getName(), organization.isPrivate());
} }
} }

View file

@ -1,4 +1,4 @@
package net.beetechgroup.beetask.usecase.organization.create; package net.beetechgroup.beetask.usecase.organization.create;
public record CreateOrganizationOutput(Long id, String name) { public record CreateOrganizationOutput(Long id, String name, boolean isPrivate) {
} }

View file

@ -45,9 +45,10 @@ public class CreateOrganizationUseCase {
} }
User user = userOptional.get(); User user = userOptional.get();
LOGGER.infof("Creating organization '%s' for owner %s", trimmedName, input.userEmail()); LOGGER.infof("Creating organization '%s' (private=%b) for owner %s", trimmedName, input.isPrivate(), input.userEmail());
Organization organization = new Organization(); Organization organization = new Organization();
organization.setName(trimmedName); organization.setName(trimmedName);
organization.setPrivate(input.isPrivate());
organization = organizationRepository.saveOrganization(organization); organization = organizationRepository.saveOrganization(organization);
UserOrganization userOrganization = new UserOrganization(); UserOrganization userOrganization = new UserOrganization();

View file

@ -38,6 +38,11 @@ public class RequestJoinOrganizationUseCase {
User user = userOptional.get(); User user = userOptional.get();
Organization organization = orgOptional.get(); Organization organization = orgOptional.get();
if (organization.isPrivate()) {
LOGGER.warnf("User %s attempted to request join into private organization %d ('%s')", userEmail, organizationId, organization.getName());
throw new IllegalStateException("Esta organização é privada e não permite solicitações de entrada.");
}
Optional<UserOrganization> existing = userOrganizationRepository.findByUserAndOrganization(user.getId(), organization.getId()); Optional<UserOrganization> existing = userOrganizationRepository.findByUserAndOrganization(user.getId(), organization.getId());
if (existing.isPresent()) { if (existing.isPresent()) {
if (existing.get().getStatus() == UserOrganizationStatus.ACTIVE) { if (existing.get().getStatus() == UserOrganizationStatus.ACTIVE) {

View file

@ -55,9 +55,10 @@ public class CreateUserUseCase {
counter++; counter++;
} }
LOGGER.infof("Creating initial workspace '%s' for newly registered user %d (%s)", uniqueWorkspaceName, user.getId(), user.getEmail()); LOGGER.infof("Creating initial private workspace '%s' for newly registered user %d (%s)", uniqueWorkspaceName, user.getId(), user.getEmail());
Organization organization = new Organization(); Organization organization = new Organization();
organization.setName(uniqueWorkspaceName); organization.setName(uniqueWorkspaceName);
organization.setPrivate(true); // Workspace pessoal do usuário é sempre privado
organization = organizationRepository.saveOrganization(organization); organization = organizationRepository.saveOrganization(organization);
UserOrganization userOrganization = new UserOrganization(); UserOrganization userOrganization = new UserOrganization();

View file

@ -39,7 +39,8 @@ public class GetUserProfileUseCase {
return new UserProfileOutput.OrganizationProfileOutput( return new UserProfileOutput.OrganizationProfileOutput(
entry.getKey(), entry.getKey(),
firstUo.getOrganization().getName(), firstUo.getOrganization().getName(),
entry.getValue().stream().map(uo -> uo.getRole().name()).collect(Collectors.toList()) entry.getValue().stream().map(uo -> uo.getRole().name()).collect(Collectors.toList()),
firstUo.getOrganization().isPrivate()
); );
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());

View file

@ -12,6 +12,7 @@ public record UserProfileOutput(
public record OrganizationProfileOutput( public record OrganizationProfileOutput(
Long id, Long id,
String name, String name,
List<String> roles List<String> roles,
boolean isPrivate
) {} ) {}
} }

View file

@ -0,0 +1,12 @@
-- Add is_private column to organizations table
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS is_private BOOLEAN NOT NULL DEFAULT false;
-- Personal organizations must always be private
UPDATE organizations
SET is_private = true
WHERE lower(name) LIKE '%pessoal%' OR lower(name) LIKE 'workspace de%';
-- Index for fast search filtering of public organizations
CREATE INDEX IF NOT EXISTS idx_organizations_public_search
ON organizations (lower(name))
WHERE is_private = false AND deleted_at IS NULL;

View file

@ -20,6 +20,7 @@ import {
Square, Square,
Sun, Sun,
Moon, Moon,
Lock,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { useTheme } from '../../contexts/ThemeContext' import { useTheme } from '../../contexts/ThemeContext'
@ -152,7 +153,10 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
{org.name.slice(0, 2).toUpperCase()} {org.name.slice(0, 2).toUpperCase()}
</div> </div>
<div className="truncate"> <div className="truncate">
<p className="truncate leading-tight">{org.name}</p> <p className="truncate leading-tight flex items-center gap-1">
<span>{org.name}</span>
{org.isPrivate && <Lock className="w-2.5 h-2.5 text-amber-500/80 shrink-0" />}
</p>
<p className="text-[10px] opacity-75 font-medium"> <p className="text-[10px] opacity-75 font-medium">
{org.roles.join(', ')} {org.roles.join(', ')}
</p> </p>

View file

@ -20,6 +20,7 @@ import {
User, User,
LogOut, LogOut,
FileText, FileText,
Lock,
} from 'lucide-react' } from 'lucide-react'
import { Chip, ChipLabel } from '@heroui/react' import { Chip, ChipLabel } from '@heroui/react'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
@ -211,7 +212,10 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
{org.name.slice(0, 2).toUpperCase()} {org.name.slice(0, 2).toUpperCase()}
</div> </div>
<div className="truncate"> <div className="truncate">
<p className="truncate leading-tight">{org.name}</p> <p className="truncate leading-tight flex items-center gap-1">
<span>{org.name}</span>
{org.isPrivate && <Lock className="w-2.5 h-2.5 text-amber-500/80 shrink-0" />}
</p>
<p className="text-[9px] text-muted font-normal"> <p className="text-[9px] text-muted font-normal">
{org.roles.join(', ')} {org.roles.join(', ')}
</p> </p>

View file

@ -14,6 +14,8 @@ import {
Check, Check,
Loader2, Loader2,
Inbox, Inbox,
Lock,
Globe,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { import {
@ -32,6 +34,7 @@ export const OrganizationsPage = () => {
// Create Org states // Create Org states
const [newOrgName, setNewOrgName] = useState('') const [newOrgName, setNewOrgName] = useState('')
const [isPrivateOrg, setIsPrivateOrg] = useState(false)
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
// Search & Join states // Search & Join states
@ -75,11 +78,12 @@ export const OrganizationsPage = () => {
setSuccessMessage(null) setSuccessMessage(null)
try { try {
const created = await organizationService.create(newOrgName.trim()) const created = await organizationService.create(newOrgName.trim(), isPrivateOrg)
await refreshUser() await refreshUser()
setActiveOrg(created.id) setActiveOrg(created.id)
setSuccessMessage(`Workspace "${newOrgName}" criado com sucesso!`) setSuccessMessage(`Workspace "${newOrgName}" criado com sucesso!`)
setNewOrgName('') setNewOrgName('')
setIsPrivateOrg(false)
setTimeout(() => { setTimeout(() => {
navigate('/dashboard') navigate('/dashboard')
}, 800) }, 800)
@ -324,6 +328,17 @@ export const OrganizationsPage = () => {
Ativo Ativo
</span> </span>
)} )}
{org.isPrivate ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-300 text-[10px] font-medium border border-amber-500/20">
<Lock className="w-2.5 h-2.5" />
<span>Privado</span>
</span>
) : (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-400 text-[10px] font-medium border border-blue-500/20">
<Globe className="w-2.5 h-2.5" />
<span>Público</span>
</span>
)}
</div> </div>
<p className="text-xs text-muted mt-0.5 font-medium"> <p className="text-xs text-muted mt-0.5 font-medium">
Papel:{' '} Papel:{' '}
@ -642,6 +657,57 @@ export const OrganizationsPage = () => {
/> />
</div> </div>
<div className="space-y-1.5 pt-1">
<label className="text-xs font-bold text-foreground block">
Visibilidade do Workspace
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<button
type="button"
onClick={() => setIsPrivateOrg(false)}
className={`p-3 rounded-2xl border text-left transition-all cursor-pointer flex items-start gap-2.5 ${
!isPrivateOrg
? 'border-brand bg-brand/10 ring-1 ring-brand/40 shadow-xs'
: 'border-border-soft bg-surface hover:bg-surface-raised'
}`}
>
<div className={`p-1.5 rounded-lg shrink-0 ${!isPrivateOrg ? 'bg-brand text-white' : 'bg-surface-sunken text-muted'}`}>
<Globe className="w-4 h-4" />
</div>
<div className="min-w-0">
<p className={`text-xs font-bold ${!isPrivateOrg ? 'text-brand' : 'text-foreground'}`}>
Público (Corporativo)
</p>
<p className="text-[11px] text-muted leading-tight mt-0.5">
Aparece nas buscas para seus colaboradores solicitarem entrada.
</p>
</div>
</button>
<button
type="button"
onClick={() => setIsPrivateOrg(true)}
className={`p-3 rounded-2xl border text-left transition-all cursor-pointer flex items-start gap-2.5 ${
isPrivateOrg
? 'border-amber-500/80 bg-amber-500/10 ring-1 ring-amber-500/40 shadow-xs'
: 'border-border-soft bg-surface hover:bg-surface-raised'
}`}
>
<div className={`p-1.5 rounded-lg shrink-0 ${isPrivateOrg ? 'bg-amber-500 text-white' : 'bg-surface-sunken text-muted'}`}>
<Lock className="w-4 h-4" />
</div>
<div className="min-w-0">
<p className={`text-xs font-bold ${isPrivateOrg ? 'text-amber-400' : 'text-foreground'}`}>
Privado
</p>
<p className="text-[11px] text-muted leading-tight mt-0.5">
Oculto nas buscas. Não aceita pedidos de entrada externos.
</p>
</div>
</button>
</div>
</div>
<button <button
type="submit" type="submit"
disabled={isCreating || !newOrgName.trim()} disabled={isCreating || !newOrgName.trim()}

View file

@ -4,6 +4,7 @@ export type OrganizationMembership = {
id: number id: number
name: string name: string
roles: string[] roles: string[]
isPrivate?: boolean
} }
export type LoginResponse = { export type LoginResponse = {

View file

@ -3,7 +3,7 @@ import { apiFetch } from '../lib/api'
export type CreateOrgResponse = { export type CreateOrgResponse = {
id: number id: number
name: string name: string
ownerId: number isPrivate?: boolean
} }
export type SearchOrgOutput = { export type SearchOrgOutput = {
@ -33,10 +33,10 @@ export type PendingJoinRequest = {
} }
export const organizationService = { export const organizationService = {
async create(name: string): Promise<CreateOrgResponse> { async create(name: string, isPrivate: boolean = false): Promise<CreateOrgResponse> {
return apiFetch<CreateOrgResponse>('/organizations', { return apiFetch<CreateOrgResponse>('/organizations', {
method: 'POST', method: 'POST',
body: { name }, body: { name, isPrivate },
}) })
}, },