Add NeuralNet private messaging MVP
mindflux-ci / build (push) Successful in 23s

This commit is contained in:
AliceDarkest
2026-07-14 15:53:07 +02:00
parent a484416ea0
commit 49d588b609
17 changed files with 260 additions and 47 deletions
@@ -42,7 +42,7 @@ public class SecurityConfig {
.csrf(csrf -> csrf
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
// Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar.
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**", "/api/serviceclash/**", "/api/networker/**")
.ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**", "/api/serviceclash/**", "/api/networker/**", "/api/neuralnet/**")
);
return http.build();
@@ -0,0 +1,20 @@
package com.example.mindflux.controller;
import com.example.mindflux.service.NeuralNetService;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.Map;
@RestController
@RequestMapping("/api/neuralnet")
public class NeuralNetApiController {
private final NeuralNetService service;
public NeuralNetApiController(NeuralNetService service) { this.service=service; }
@GetMapping("/overview") public NeuralNetService.Overview overview(Principal p) { return service.overview(p.getName()); }
@PostMapping("/enable") public void enable(Principal p) { service.enable(p.getName()); }
@PostMapping("/requests") public void request(Principal p,@RequestBody Map<String,String> body) { service.request(p.getName(),body.get("username")); }
@PostMapping("/requests/{username}/accept") public void accept(Principal p,@PathVariable String username) { service.accept(p.getName(),username); }
@PostMapping("/requests/{username}/decline") public void decline(Principal p,@PathVariable String username) { service.decline(p.getName(),username); }
@DeleteMapping("/requests/{username}") public void cancel(Principal p,@PathVariable String username) { service.cancel(p.getName(),username); }
@GetMapping("/messages/{username}") public Object messages(Principal p,@PathVariable String username) { return service.conversation(p.getName(),username); }
@PostMapping("/messages") public Object send(Principal p,@RequestBody Map<String,String> body) { return service.send(p.getName(),body.get("recipient"),body.get("body")); }
}
@@ -0,0 +1,21 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "neuralnet_connections")
public class NeuralNetConnection {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String requester;
private String addressee;
private String status;
@Column(name = "created_at") private LocalDateTime createdAt;
@Column(name = "responded_at") private LocalDateTime respondedAt;
public NeuralNetConnection() {}
public NeuralNetConnection(String requester, String addressee) { this.requester=requester; this.addressee=addressee; this.status="PENDING"; this.createdAt=LocalDateTime.now(); }
public Long getId() { return id; } public String getRequester() { return requester; } public String getAddressee() { return addressee; }
public String getStatus() { return status; } public void setStatus(String status) { this.status=status; }
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getRespondedAt() { return respondedAt; }
public void setRespondedAt(LocalDateTime respondedAt) { this.respondedAt=respondedAt; }
}
@@ -0,0 +1,21 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "neuralnet_messages")
public class NeuralNetMessage {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String sender;
private String recipient;
private String body;
@Column(name = "sent_at") private LocalDateTime sentAt;
@Column(name = "read_at") private LocalDateTime readAt;
private boolean reported;
public NeuralNetMessage() {}
public NeuralNetMessage(String sender, String recipient, String body) { this.sender=sender; this.recipient=recipient; this.body=body; this.sentAt=LocalDateTime.now(); }
public Long getId() { return id; } public String getSender() { return sender; } public String getRecipient() { return recipient; }
public String getBody() { return body; } public LocalDateTime getSentAt() { return sentAt; } public LocalDateTime getReadAt() { return readAt; }
public void setReadAt(LocalDateTime readAt) { this.readAt=readAt; } public boolean isReported() { return reported; } public void setReported(boolean reported) { this.reported=reported; }
}
@@ -0,0 +1,17 @@
package com.example.mindflux.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "neuralnet_privacy")
public class NeuralNetPrivacy {
@Id private String username;
private boolean enabled;
public NeuralNetPrivacy() {}
public NeuralNetPrivacy(String username, boolean enabled) { this.username = username; this.enabled = enabled; }
public String getUsername() { return username; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
}
@@ -0,0 +1,14 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.NeuralNetConnection;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import java.util.*;
public interface NeuralNetConnectionRepository extends JpaRepository<NeuralNetConnection, Long> {
Optional<NeuralNetConnection> findByRequesterAndAddressee(String requester, String addressee);
List<NeuralNetConnection> findByAddresseeAndStatusOrderByCreatedAtAsc(String addressee, String status);
List<NeuralNetConnection> findByRequesterAndStatusOrderByCreatedAtAsc(String requester, String status);
@Query("select c from NeuralNetConnection c where c.status='ACCEPTED' and ((c.requester=:username) or (c.addressee=:username)) order by c.respondedAt desc")
List<NeuralNetConnection> acceptedFor(@Param("username") String username);
@Query("select c from NeuralNetConnection c where c.status='ACCEPTED' and ((c.requester=:a and c.addressee=:b) or (c.requester=:b and c.addressee=:a))")
Optional<NeuralNetConnection> acceptedBetween(@Param("a") String a, @Param("b") String b);
}
@@ -0,0 +1,10 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.NeuralNetMessage;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import java.util.*;
public interface NeuralNetMessageRepository extends JpaRepository<NeuralNetMessage, Long> {
@Query("select m from NeuralNetMessage m where (m.sender=:a and m.recipient=:b) or (m.sender=:b and m.recipient=:a) order by m.sentAt asc")
List<NeuralNetMessage> conversation(@Param("a") String a, @Param("b") String b);
long countByRecipientAndReadAtIsNull(String recipient);
}
@@ -0,0 +1,4 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.NeuralNetPrivacy;
import org.springframework.data.jpa.repository.JpaRepository;
public interface NeuralNetPrivacyRepository extends JpaRepository<NeuralNetPrivacy, String> {}
@@ -0,0 +1,62 @@
package com.example.mindflux.service;
import com.example.mindflux.model.*;
import com.example.mindflux.repository.*;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class NeuralNetService {
private final NeuralNetPrivacyRepository privacy;
private final NeuralNetConnectionRepository connections;
private final NeuralNetMessageRepository messages;
private final UserRepository users;
public NeuralNetService(NeuralNetPrivacyRepository privacy, NeuralNetConnectionRepository connections, NeuralNetMessageRepository messages, UserRepository users) {
this.privacy=privacy; this.connections=connections; this.messages=messages; this.users=users;
}
public record Overview(String username, boolean enabled, List<String> connections, List<String> incoming, List<String> outgoing, long unread) {}
public record ChatMessage(long id, String sender, String body, LocalDateTime sentAt) {}
private boolean enabled(String username) { return privacy.findById(username).map(NeuralNetPrivacy::isEnabled).orElse(false); }
private void requireEnabled(String username) { if (!enabled(username)) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "NeuralNet ist für dieses Konto nicht aktiviert."); }
private void requireConnection(String a, String b) { if (connections.acceptedBetween(a,b).isEmpty()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Private Nachrichten sind erst nach bestätigter Verbindung möglich."); }
public void enable(String username) { privacy.save(new NeuralNetPrivacy(username, true)); }
@Transactional(readOnly = true)
public Overview overview(String username) {
if (!enabled(username)) return new Overview(username, false, List.of(), List.of(), List.of(), 0);
List<String> accepted=connections.acceptedFor(username).stream().map(c -> c.getRequester().equals(username) ? c.getAddressee() : c.getRequester()).toList();
List<String> incoming=connections.findByAddresseeAndStatusOrderByCreatedAtAsc(username,"PENDING").stream().map(NeuralNetConnection::getRequester).toList();
List<String> outgoing=connections.findByRequesterAndStatusOrderByCreatedAtAsc(username,"PENDING").stream().map(NeuralNetConnection::getAddressee).toList();
return new Overview(username, true, accepted, incoming, outgoing, messages.countByRecipientAndReadAtIsNull(username));
}
public void request(String username, String target) {
requireEnabled(username); target=cleanUser(target);
var targetUser=users.findByUsernameIgnoreCase(target).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Dieses NeuralNet-Mitglied ist nicht verfügbar."));
target=targetUser.getUsername();
if (username.equalsIgnoreCase(target) || !enabled(target)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dieses NeuralNet-Mitglied ist nicht verfügbar.");
if (connections.findByRequesterAndAddressee(username,target).isPresent() || connections.findByRequesterAndAddressee(target,username).isPresent()) throw new ResponseStatusException(HttpStatus.CONFLICT, "Zwischen euch besteht bereits eine Anfrage oder Verbindung.");
connections.save(new NeuralNetConnection(username,target));
}
public void accept(String username, String requester) { var c=pendingFor(username,requester); c.setStatus("ACCEPTED"); c.setRespondedAt(LocalDateTime.now()); connections.save(c); }
public void decline(String username, String requester) { connections.delete(pendingFor(username,requester)); }
public void cancel(String username, String target) { connections.delete(connections.findByRequesterAndAddressee(username,cleanUser(target)).filter(c -> c.getStatus().equals("PENDING")).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Anfrage nicht gefunden."))); }
@Transactional
public List<ChatMessage> conversation(String username, String other) {
requireEnabled(username); other=cleanUser(other); requireConnection(username,other);
var result=messages.conversation(username,other);
result.stream().filter(m -> m.getRecipient().equals(username) && m.getReadAt()==null).forEach(m -> m.setReadAt(LocalDateTime.now()));
return result.stream().map(m -> new ChatMessage(m.getId(),m.getSender(),m.getBody(),m.getSentAt())).toList();
}
public ChatMessage send(String username, String recipient, String body) {
requireEnabled(username); recipient=cleanUser(recipient); requireConnection(username,recipient);
String text=body==null ? "" : body.trim();
if (text.isBlank() || text.length()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Eine Nachricht benötigt 1 bis 2000 Zeichen.");
var m=messages.save(new NeuralNetMessage(username,recipient,text));
return new ChatMessage(m.getId(),m.getSender(),m.getBody(),m.getSentAt());
}
private NeuralNetConnection pendingFor(String addressee,String requester) { return connections.findByRequesterAndAddressee(cleanUser(requester),addressee).filter(c -> c.getStatus().equals("PENDING")).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,"Anfrage nicht gefunden.")); }
private String cleanUser(String username) { if (username==null || username.trim().isBlank() || username.trim().length()>100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Ungültiger Benutzername."); return username.trim(); }
}
@@ -0,0 +1,28 @@
CREATE TABLE neuralnet_privacy (
username VARCHAR(100) PRIMARY KEY REFERENCES users(username) ON DELETE CASCADE ON UPDATE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE neuralnet_connections (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
requester VARCHAR(100) NOT NULL REFERENCES users(username) ON DELETE CASCADE ON UPDATE CASCADE,
addressee VARCHAR(100) NOT NULL REFERENCES users(username) ON DELETE CASCADE ON UPDATE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL,
responded_at TIMESTAMP,
CONSTRAINT uq_neuralnet_connection UNIQUE (requester, addressee),
CONSTRAINT chk_neuralnet_no_self CHECK (requester <> addressee)
);
CREATE INDEX idx_neuralnet_connection_addressee ON neuralnet_connections(addressee, status);
CREATE TABLE neuralnet_messages (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
sender VARCHAR(100) NOT NULL REFERENCES users(username) ON DELETE CASCADE ON UPDATE CASCADE,
recipient VARCHAR(100) NOT NULL REFERENCES users(username) ON DELETE CASCADE ON UPDATE CASCADE,
body VARCHAR(2000) NOT NULL,
sent_at TIMESTAMP NOT NULL,
read_at TIMESTAMP,
reported BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_neuralnet_message_pair ON neuralnet_messages(sender, recipient, sent_at);
CREATE INDEX idx_neuralnet_message_unread ON neuralnet_messages(recipient, read_at);
@@ -0,0 +1 @@
.nn-head{margin:1.3rem 0}.nn-head>span,.nn-chat small{color:var(--accent);font:700 .72rem/1 monospace;letter-spacing:.14em}.nn-head h1{margin:.4rem 0;color:var(--heading)}.nn-head p{color:var(--muted)}.nn-optin{max-width:700px}.nn-optin p{color:var(--muted);line-height:1.6}.nn-app{display:grid;grid-template-columns:minmax(270px,340px) 1fr;gap:1rem}.nn-sidebar{display:grid;align-content:start;gap:1rem}.nn-request div{display:flex;gap:.45rem}.nn-request .mf-input{min-width:0}.nn-section{border-top:1px solid var(--border);padding-top:.85rem}.nn-section h2{font-size:.82rem;color:var(--heading);margin:0 0 .5rem}.nn-list{display:grid;gap:.45rem}.nn-item{display:flex;align-items:center;justify-content:space-between;gap:.5rem;border:1px solid var(--border);background:var(--surface-2);padding:.55rem .65rem;border-radius:8px;color:var(--text);font-size:.84rem}.nn-item button{border:0;background:none;color:var(--secondary);font:inherit;cursor:pointer}.nn-item button:hover,.nn-item button.active{color:var(--accent)}.nn-actions{display:flex;gap:.35rem}.nn-actions button{border:1px solid var(--border);padding:.22rem .38rem;border-radius:5px}.nn-chat{min-height:530px}.nn-empty{height:100%;min-height:430px;display:grid;place-content:center;text-align:center;gap:.5rem;color:var(--muted)}.nn-empty strong{color:var(--heading);font-size:1.1rem}.nn-chat header{border-bottom:1px solid var(--border);padding-bottom:.9rem}.nn-chat h2{margin:.25rem 0 0;color:var(--heading)}.nn-messages{display:flex;flex-direction:column;gap:.6rem;height:380px;overflow:auto;padding:1rem 0}.nn-message{align-self:flex-start;max-width:min(80%,550px);padding:.65rem .8rem;border-radius:10px;background:var(--surface-2);border:1px solid var(--border);color:var(--text);line-height:1.45}.nn-message.mine{align-self:flex-end;border-color:var(--border-accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}.nn-message small{display:block;margin-top:.35rem;color:var(--muted);font-size:.66rem}.nn-compose{display:grid;grid-template-columns:1fr auto;gap:.6rem;border-top:1px solid var(--border);padding-top:.85rem}.nn-compose textarea{min-height:52px;resize:vertical;background:var(--surface-2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.65rem;font:inherit}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:800px){.nn-app{grid-template-columns:1fr}.nn-chat{min-height:400px}.nn-messages{height:300px}.nn-request div{flex-wrap:wrap}.nn-request .mf-input{flex:1}}
@@ -0,0 +1 @@
(function(){'use strict';var selected=null,me='',poll=null;function q(s){return document.querySelector(s)}function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}async function api(url,method,body){var r=await fetch('/api/neuralnet'+url,{method:method||'GET',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});if(!r.ok){var e=await r.json().catch(()=>({message:'Anfrage fehlgeschlagen.'}));throw new Error(e.message)}return r.status===204?null:r.json()}function item(name,actions){return '<div class="nn-item"><span>'+esc(name)+'</span><span class="nn-actions">'+actions+'</span></div>'}function render(data){q('[data-nn-incoming]').innerHTML=data.incoming.length?data.incoming.map(n=>item(n,'<button data-action="accept" data-user="'+esc(n)+'">Annehmen</button><button data-action="decline" data-user="'+esc(n)+'">Ablehnen</button>')).join(''):'<span class="mf-small-hint">Keine offenen Anfragen.</span>';q('[data-nn-outgoing]').innerHTML=data.outgoing.length?data.outgoing.map(n=>item(n,'<button data-action="cancel" data-user="'+esc(n)+'">Zurückziehen</button>')).join(''):'<span class="mf-small-hint">Keine gesendeten Anfragen.</span>';q('[data-nn-connections]').innerHTML=data.connections.length?data.connections.map(n=>item(n,'<button data-action="chat" data-user="'+esc(n)+'" class="'+(selected===n?'active':'')+'">Chat</button>')).join(''):'<span class="mf-small-hint">Noch keine Verbindungen.</span>'}async function refresh(){var data=await api('/overview');me=data.username;q('#nnOptIn').hidden=data.enabled;q('#nnApp').hidden=!data.enabled;if(data.enabled)render(data)}async function loadChat(){if(!selected)return;var rows=await api('/messages/'+encodeURIComponent(selected));q('[data-nn-empty]').hidden=true;q('[data-nn-conversation]').hidden=false;q('[data-nn-partner]').textContent=selected;q('[data-nn-messages]').innerHTML=rows.map(m=>'<article class="nn-message '+(m.sender===me?'mine':'')+'">'+esc(m.body)+'<small>'+esc(m.sender)+' · '+new Date(m.sentAt).toLocaleString('de-DE',{dateStyle:'short',timeStyle:'short'})+'</small></article>').join('');var box=q('[data-nn-messages]');box.scrollTop=box.scrollHeight}function message(text){var old=q('.nn-head p');old.textContent=text;setTimeout(()=>old.textContent='Erst eine bestätigte Anfrage, dann ein geschützter Nachrichtenkanal.',3500)}function init(){q('[data-nn-enable]').onclick=async()=>{await api('/enable','POST');refresh()};q('[data-nn-request]').onsubmit=async e=>{e.preventDefault();try{await api('/requests','POST',{username:q('#nn-user').value});q('#nn-user').value='';message('Kontaktanfrage gesendet.')}catch(x){message(x.message)}refresh()};q('.nn-sidebar').onclick=async e=>{var b=e.target.closest('[data-action]');if(!b)return;var u=b.dataset.user,a=b.dataset.action;try{if(a==='chat'){selected=u;await refresh();await loadChat();return}if(a==='accept')await api('/requests/'+encodeURIComponent(u)+'/accept','POST');if(a==='decline')await api('/requests/'+encodeURIComponent(u)+'/decline','POST');if(a==='cancel')await api('/requests/'+encodeURIComponent(u),'DELETE');await refresh()}catch(x){message(x.message)}};q('[data-nn-compose]').onsubmit=async e=>{e.preventDefault();try{await api('/messages','POST',{recipient:selected,body:q('#nn-message').value});q('#nn-message').value='';loadChat()}catch(x){message(x.message)}};refresh().catch(x=>message(x.message));poll=setInterval(()=>{refresh();if(selected)loadChat()},5000)}if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init()})();
+1 -1
View File
@@ -64,7 +64,7 @@
</svg>
<span class="ft-trigger-dot" aria-hidden="true"></span>
</button>
<button class="mf-iconbtn" type="button" aria-label="Benachrichtigungen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></button>
<a class="mf-iconbtn" href="/soziales" aria-label="NeuralNet öffnen" title="NeuralNet"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></a>
<div class="mf-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="Suche..."></div>
<button class="mf-theme"></button>
<form th:action="@{/logout}" method="post" style="display:inline">
+24 -45
View File
@@ -1,51 +1,30 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SysnaptiK Neural Soziales</title>
<link rel="stylesheet" href="/css/theme.css?v=3">
<script src="/js/theme.js?v=3"></script>
<style>
.mf-soziales { text-align:center; padding:2.5rem 1.5rem; }
.mf-soziales h1 { color:var(--heading); font-size:1.6rem; line-height:1.4; margin-bottom:1rem; }
.mf-soziales p { color:var(--text); line-height:1.6; }
.mf-time-machine { font-size:3rem; margin:1.6rem 0; filter:drop-shadow(0 0 12px var(--glow-s)); }
.mf-small-hint { color:var(--muted); font-size:.86rem; margin-top:1rem; }
</style>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeuralNet – SysnaptiK Neural</title>
<link rel="stylesheet" href="/css/theme.css?v=3"><link rel="stylesheet" href="/css/neuralnet.css?v=1">
<script src="/js/theme.js?v=3"></script><script src="/js/neuralnet.js?v=1" defer></script>
</head>
<body>
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<div class="mf-container">
<div class="mf-page-head">
<a th:href="@{/dashboard}" class="mf-back">⟵ Zurück zum Dashboard</a>
</div>
<nav th:replace="~{fragments/layout :: profileTabs('soziales')}"></nav>
<div class="mf-panel accent mf-soziales">
<h1>„Straßen? Wo wir hingehen, brauchen wir keine Straßen!“ 🚀</h1>
<p>Das soziale Netzwerk für SysnaptiK Neural-User befindet sich noch in Entwicklung.<br>
Bald kannst du dich hier mit anderen Neural-Mitgliedern verbinden!</p>
<div class="mf-time-machine">
🚗💨💥
</div>
<p class="mf-small-hint">Zurück in die Zukunft... und bald auch ins SysnaptiK Neural-Netzwerk!</p>
</div>
</div>
<footer class="mf-footer">
<a href="#">Support</a>
<a href="#">Feedback</a>
<a href="/nutzungsbedingungen">Nutzungsbedingungen</a>
<a href="/richtlinien">Richtlinien</a>
<a href="/datenschutz">Datenschutz</a>
<a href="/impressum">Impressum</a>
</footer>
</body>
</html>
<main class="mf-container">
<div class="mf-page-head"><a th:href="@{/dashboard}" class="mf-back">⟵ Zurück zum Dashboard</a></div>
<nav th:replace="~{fragments/layout :: profileTabs('soziales')}"></nav>
<section class="nn-head"><span>NEURALNET</span><h1>Private Lernverbindungen</h1><p>Erst eine bestätigte Anfrage, dann ein geschützter Nachrichtenkanal.</p></section>
<section id="nnOptIn" class="mf-panel nn-optin" hidden><h2>NeuralNet aktivieren</h2><p>Mit der Aktivierung wirst du für andere Mitglieder auffindbar und kannst Kontaktanfragen senden oder erhalten. Private Nachrichten sind erst nach gegenseitiger Verbindung möglich.</p><button class="mf-btn" data-nn-enable>NeuralNet aktivieren</button></section>
<section id="nnApp" class="nn-app" hidden>
<aside class="mf-panel nn-sidebar">
<form class="nn-request" data-nn-request><label class="mf-label" for="nn-user">Verbindung anfragen</label><div><input class="mf-input" id="nn-user" maxlength="100" placeholder="Benutzername" required><button class="mf-btn" type="submit">Senden</button></div></form>
<div class="nn-section"><h2>Offene Anfragen</h2><div data-nn-incoming class="nn-list"></div></div>
<div class="nn-section"><h2>Gesendet</h2><div data-nn-outgoing class="nn-list"></div></div>
<div class="nn-section"><h2>Verbindungen</h2><div data-nn-connections class="nn-list"></div></div>
</aside>
<section class="mf-panel nn-chat">
<div data-nn-empty class="nn-empty"><strong>Wähle eine Verbindung</strong><span>Private Nachrichten werden erst nach einer angenommenen Anfrage freigeschaltet.</span></div>
<div data-nn-conversation hidden><header><div><small>PRIVATE VERBINDUNG</small><h2 data-nn-partner></h2></div></header><div class="nn-messages" data-nn-messages aria-live="polite"></div><form class="nn-compose" data-nn-compose><label class="sr-only" for="nn-message">Nachricht</label><textarea id="nn-message" maxlength="2000" placeholder="Nachricht schreiben …" required></textarea><button class="mf-btn" type="submit">Senden</button></form></div>
</section>
</section>
</main>
<footer class="mf-footer"><a href="#">Support</a><a href="#">Feedback</a><a href="/nutzungsbedingungen">Nutzungsbedingungen</a><a href="/richtlinien">Richtlinien</a><a href="/datenschutz">Datenschutz</a><a href="/impressum">Impressum</a></footer>
</body></html>
@@ -0,0 +1,28 @@
package com.example.mindflux.service;
import com.example.mindflux.model.NeuralNetPrivacy;
import com.example.mindflux.repository.*;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
class NeuralNetServiceTest {
@Test
void rejectsMessagesBeforeTheConnectionIsAccepted() {
var privacy = mock(NeuralNetPrivacyRepository.class);
var connections = mock(NeuralNetConnectionRepository.class);
var messages = mock(NeuralNetMessageRepository.class);
var users = mock(UserRepository.class);
when(privacy.findById("alice")).thenReturn(Optional.of(new NeuralNetPrivacy("alice", true)));
when(connections.acceptedBetween("alice", "bob")).thenReturn(Optional.empty());
var service = new NeuralNetService(privacy, connections, messages, users);
assertThatThrownBy(() -> service.send("alice", "bob", "Hallo"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("bestätigter Verbindung");
verify(messages, never()).save(any());
}
}