feat(org): adicionar visibilidade publica e privada com protecao em buscas e join requests
This commit is contained in:
parent
e7703f34e7
commit
3dff934d61
26 changed files with 149 additions and 26 deletions
|
|
@ -7,6 +7,7 @@ import net.beetechgroup.beetask.entities.Project;
|
|||
public class Organization {
|
||||
private Long id;
|
||||
private String name;
|
||||
private boolean isPrivate;
|
||||
private List<Project> projects;
|
||||
private List<UserOrganization> users;
|
||||
|
||||
|
|
@ -26,6 +27,14 @@ public class Organization {
|
|||
this.name = name;
|
||||
}
|
||||
|
||||
public boolean isPrivate() {
|
||||
return isPrivate;
|
||||
}
|
||||
|
||||
public void setPrivate(boolean isPrivate) {
|
||||
this.isPrivate = isPrivate;
|
||||
}
|
||||
|
||||
public List<Project> getProjects() {
|
||||
return projects;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ public class OrganizationEntity {
|
|||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
private String name;
|
||||
@Column(name = "is_private", nullable = false)
|
||||
private boolean isPrivate;
|
||||
@Column(name = "deleted_at")
|
||||
private java.time.LocalDateTime deletedAt;
|
||||
@OneToMany(mappedBy = "organization")
|
||||
|
|
@ -50,6 +52,14 @@ public class OrganizationEntity {
|
|||
this.name = name;
|
||||
}
|
||||
|
||||
public boolean isPrivate() {
|
||||
return isPrivate;
|
||||
}
|
||||
|
||||
public void setPrivate(boolean isPrivate) {
|
||||
this.isPrivate = isPrivate;
|
||||
}
|
||||
|
||||
public java.time.LocalDateTime getDeletedAt() {
|
||||
return deletedAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ public class OrganizationEntityMapper {
|
|||
OrganizationEntity entity = new OrganizationEntity();
|
||||
entity.setId(organization.getId());
|
||||
entity.setName(organization.getName());
|
||||
entity.setPrivate(organization.isPrivate());
|
||||
return entity;
|
||||
}
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ public class OrganizationEntityMapper {
|
|||
Organization organization = new Organization();
|
||||
organization.setId(entity.getId());
|
||||
organization.setName(entity.getName());
|
||||
organization.setPrivate(entity.isPrivate());
|
||||
return organization;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,17 +34,17 @@ public class OrganizationRepositoryImpl implements OrganizationRepository, Panac
|
|||
@Override
|
||||
public List<Organization> search(String query) {
|
||||
if (Objects.isNull(query) || query.isBlank()) {
|
||||
List<Organization> organizations = findAll().list().stream()
|
||||
List<Organization> organizations = find("isPrivate = false").list().stream()
|
||||
.map(OrganizationEntityMapper::toDomain)
|
||||
.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;
|
||||
}
|
||||
|
||||
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)
|
||||
.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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ public class AuthControllerMapper {
|
|||
output.expiresIn(),
|
||||
output.issuedAt(),
|
||||
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())
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ public record LoginResponse(
|
|||
public record Organization(
|
||||
Long id,
|
||||
String name,
|
||||
List<String> roles
|
||||
List<String> roles,
|
||||
boolean isPrivate
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.organization;
|
||||
|
||||
public record CreateOrganizationRequest(
|
||||
String name) {
|
||||
String name,
|
||||
Boolean isPrivate) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.organization;
|
||||
|
||||
public record CreateOrganizationResponse(Long id, String name) {
|
||||
public record CreateOrganizationResponse(Long id, String name, boolean isPrivate) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import net.beetechgroup.beetask.usecase.organization.create.CreateOrganizationOu
|
|||
|
||||
public class OrganizationControllerMapper {
|
||||
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) {
|
||||
return new CreateOrganizationResponse(output.id(), output.name());
|
||||
return new CreateOrganizationResponse(output.id(), output.name(), output.isPrivate());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ public record LoginOutput(
|
|||
public record OrganizationOutput(
|
||||
Long id,
|
||||
String name,
|
||||
List<String> roles
|
||||
List<String> roles,
|
||||
boolean isPrivate
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,8 @@ public class LoginUseCase {
|
|||
return new LoginOutput.OrganizationOutput(
|
||||
entry.getKey(),
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ public class RefreshTokenUseCase {
|
|||
return new LoginOutput.OrganizationOutput(
|
||||
entry.getKey(),
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package net.beetechgroup.beetask.usecase.organization.create;
|
||||
|
||||
public record CreateOrganizationInput(String name, String userEmail) {
|
||||
public record CreateOrganizationInput(String name, String userEmail, boolean isPrivate) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ import net.beetechgroup.beetask.entities.organization.Organization;
|
|||
|
||||
public class CreateOrganizationMapper {
|
||||
public static CreateOrganizationOutput toCreateOrganizationOutput(Organization organization) {
|
||||
return new CreateOrganizationOutput(organization.getId(), organization.getName());
|
||||
return new CreateOrganizationOutput(organization.getId(), organization.getName(), organization.isPrivate());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package net.beetechgroup.beetask.usecase.organization.create;
|
||||
|
||||
public record CreateOrganizationOutput(Long id, String name) {
|
||||
public record CreateOrganizationOutput(Long id, String name, boolean isPrivate) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,9 +45,10 @@ public class CreateOrganizationUseCase {
|
|||
}
|
||||
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.setName(trimmedName);
|
||||
organization.setPrivate(input.isPrivate());
|
||||
organization = organizationRepository.saveOrganization(organization);
|
||||
|
||||
UserOrganization userOrganization = new UserOrganization();
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ public class RequestJoinOrganizationUseCase {
|
|||
User user = userOptional.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());
|
||||
if (existing.isPresent()) {
|
||||
if (existing.get().getStatus() == UserOrganizationStatus.ACTIVE) {
|
||||
|
|
|
|||
|
|
@ -55,9 +55,10 @@ public class CreateUserUseCase {
|
|||
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.setName(uniqueWorkspaceName);
|
||||
organization.setPrivate(true); // Workspace pessoal do usuário é sempre privado
|
||||
organization = organizationRepository.saveOrganization(organization);
|
||||
|
||||
UserOrganization userOrganization = new UserOrganization();
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ public class GetUserProfileUseCase {
|
|||
return new UserProfileOutput.OrganizationProfileOutput(
|
||||
entry.getKey(),
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ public record UserProfileOutput(
|
|||
public record OrganizationProfileOutput(
|
||||
Long id,
|
||||
String name,
|
||||
List<String> roles
|
||||
List<String> roles,
|
||||
boolean isPrivate
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
Square,
|
||||
Sun,
|
||||
Moon,
|
||||
Lock,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useTheme } from '../../contexts/ThemeContext'
|
||||
|
|
@ -152,7 +153,10 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
|
|||
{org.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<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">
|
||||
{org.roles.join(', ')}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
User,
|
||||
LogOut,
|
||||
FileText,
|
||||
Lock,
|
||||
} from 'lucide-react'
|
||||
import { Chip, ChipLabel } from '@heroui/react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
|
|
@ -211,7 +212,10 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
|||
{org.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<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">
|
||||
{org.roles.join(', ')}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
Check,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Lock,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import {
|
||||
|
|
@ -32,6 +34,7 @@ export const OrganizationsPage = () => {
|
|||
|
||||
// Create Org states
|
||||
const [newOrgName, setNewOrgName] = useState('')
|
||||
const [isPrivateOrg, setIsPrivateOrg] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
// Search & Join states
|
||||
|
|
@ -75,11 +78,12 @@ export const OrganizationsPage = () => {
|
|||
setSuccessMessage(null)
|
||||
|
||||
try {
|
||||
const created = await organizationService.create(newOrgName.trim())
|
||||
const created = await organizationService.create(newOrgName.trim(), isPrivateOrg)
|
||||
await refreshUser()
|
||||
setActiveOrg(created.id)
|
||||
setSuccessMessage(`Workspace "${newOrgName}" criado com sucesso!`)
|
||||
setNewOrgName('')
|
||||
setIsPrivateOrg(false)
|
||||
setTimeout(() => {
|
||||
navigate('/dashboard')
|
||||
}, 800)
|
||||
|
|
@ -324,6 +328,17 @@ export const OrganizationsPage = () => {
|
|||
Ativo
|
||||
</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>
|
||||
<p className="text-xs text-muted mt-0.5 font-medium">
|
||||
Papel:{' '}
|
||||
|
|
@ -642,6 +657,57 @@ export const OrganizationsPage = () => {
|
|||
/>
|
||||
</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
|
||||
type="submit"
|
||||
disabled={isCreating || !newOrgName.trim()}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export type OrganizationMembership = {
|
|||
id: number
|
||||
name: string
|
||||
roles: string[]
|
||||
isPrivate?: boolean
|
||||
}
|
||||
|
||||
export type LoginResponse = {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { apiFetch } from '../lib/api'
|
|||
export type CreateOrgResponse = {
|
||||
id: number
|
||||
name: string
|
||||
ownerId: number
|
||||
isPrivate?: boolean
|
||||
}
|
||||
|
||||
export type SearchOrgOutput = {
|
||||
|
|
@ -33,10 +33,10 @@ export type PendingJoinRequest = {
|
|||
}
|
||||
|
||||
export const organizationService = {
|
||||
async create(name: string): Promise<CreateOrgResponse> {
|
||||
async create(name: string, isPrivate: boolean = false): Promise<CreateOrgResponse> {
|
||||
return apiFetch<CreateOrgResponse>('/organizations', {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
body: { name, isPrivate },
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue